@houtini/lm 2.13.2 → 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 +15 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +732 -307
- 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
|
-
* return whatever content we have so far with `truncated: true`.
|
|
525
|
-
* This means large code reviews return partial results instead of nothing.
|
|
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.
|
|
526
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.
|
|
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,7 +886,7 @@ 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`);
|
|
@@ -652,231 +913,323 @@ async function chatCompletionStreamingInner(messages, options = {}) {
|
|
|
652
913
|
},
|
|
653
914
|
}).catch(() => { });
|
|
654
915
|
};
|
|
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)
|
|
928
|
+
return;
|
|
929
|
+
lastStreamProgressMs = now;
|
|
930
|
+
sendProgress(message);
|
|
931
|
+
};
|
|
655
932
|
// Ping once immediately — resets the client's timeout clock as soon as the
|
|
656
933
|
// tool call is acknowledged server-side, regardless of how long prefill or
|
|
657
934
|
// the upstream handshake takes.
|
|
658
935
|
sendProgress('Connecting to model...');
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
678
|
-
if (!res.body) {
|
|
679
|
-
throw new Error('Response body is null — streaming not supported by endpoint');
|
|
680
|
-
}
|
|
681
|
-
const reader = res.body.getReader();
|
|
682
|
-
const decoder = new TextDecoder();
|
|
683
|
-
let content = '';
|
|
684
|
-
let reasoning = '';
|
|
685
|
-
let model = '';
|
|
686
|
-
let usage;
|
|
687
|
-
let finishReason = '';
|
|
688
|
-
let truncated = false;
|
|
689
|
-
let prefillStall = false;
|
|
690
|
-
let buffer = '';
|
|
691
|
-
let ttftMs;
|
|
692
|
-
let firstChunkReceived = false;
|
|
693
|
-
// Prefill keep-alive — even after HTTP headers flush, the first SSE chunk
|
|
694
|
-
// can still lag while the model finishes prompt processing. Fire a progress
|
|
695
|
-
// notification every 10s until the first chunk arrives.
|
|
696
|
-
const keepAliveTimer = setInterval(() => {
|
|
697
|
-
if (firstChunkReceived)
|
|
698
|
-
return;
|
|
699
|
-
const waitedMs = Date.now() - startTime;
|
|
700
|
-
sendProgress(`Waiting for model... (${(waitedMs / 1000).toFixed(0)}s, still in prefill)`);
|
|
701
|
-
}, PREFILL_KEEPALIVE_MS);
|
|
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
|
+
: () => { };
|
|
702
954
|
try {
|
|
703
|
-
while
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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;
|
|
760
1091
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
ttftMs = Date.now() - startTime;
|
|
764
|
-
content += delta.content;
|
|
765
|
-
sendProgress(`Streaming... ${content.length} chars`);
|
|
1092
|
+
catch {
|
|
1093
|
+
// Skip unparseable chunks (partial JSON, comments, etc.)
|
|
766
1094
|
}
|
|
767
|
-
const reason = json.choices?.[0]?.finish_reason;
|
|
768
|
-
if (reason)
|
|
769
|
-
finishReason = reason;
|
|
770
|
-
// Some endpoints include usage in the final streaming chunk
|
|
771
|
-
if (json.usage)
|
|
772
|
-
usage = json.usage;
|
|
773
|
-
}
|
|
774
|
-
catch {
|
|
775
|
-
// Skip unparseable chunks (partial JSON, comments, etc.)
|
|
776
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;
|
|
777
1101
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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;
|
|
796
1137
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
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`);
|
|
801
1141
|
}
|
|
802
|
-
const reason = json.choices?.[0]?.finish_reason;
|
|
803
|
-
if (reason)
|
|
804
|
-
finishReason = reason;
|
|
805
|
-
if (json.usage)
|
|
806
|
-
usage = json.usage;
|
|
807
|
-
}
|
|
808
|
-
catch (e) {
|
|
809
|
-
// Incomplete JSON in final buffer — log for diagnostics
|
|
810
|
-
process.stderr.write(`[houtini-lm] Unflushed buffer parse failed (${buffer.length} bytes): ${e}\n`);
|
|
811
1142
|
}
|
|
812
1143
|
}
|
|
813
1144
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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 */ }
|
|
826
1162
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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}`);
|
|
830
1170
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
let reasoningFallback = false;
|
|
853
|
-
if (!cleanContent) {
|
|
854
|
-
if (content.trim()) {
|
|
855
|
-
thinkStripFallback = true;
|
|
856
|
-
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*/, '');
|
|
857
1192
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
+
}
|
|
864
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();
|
|
865
1232
|
}
|
|
866
|
-
return {
|
|
867
|
-
content: cleanContent,
|
|
868
|
-
rawContent: content,
|
|
869
|
-
reasoningContent: reasoning || undefined,
|
|
870
|
-
model,
|
|
871
|
-
usage,
|
|
872
|
-
finishReason,
|
|
873
|
-
truncated,
|
|
874
|
-
ttftMs,
|
|
875
|
-
generationMs,
|
|
876
|
-
thinkStripFallback,
|
|
877
|
-
reasoningFallback,
|
|
878
|
-
prefillStall,
|
|
879
|
-
};
|
|
880
1233
|
}
|
|
881
1234
|
let detectedBackend = null;
|
|
882
1235
|
function getBackend() {
|
|
@@ -1042,7 +1395,11 @@ const PREFILL_WARN_THRESHOLD_SEC = 25;
|
|
|
1042
1395
|
*/
|
|
1043
1396
|
async function estimatePrefill(inputChars, modelId) {
|
|
1044
1397
|
const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN);
|
|
1045
|
-
// 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.
|
|
1046
1403
|
try {
|
|
1047
1404
|
const samples = await getPrefillSamples(modelId);
|
|
1048
1405
|
const fit = fitPrefillLinear(samples);
|
|
@@ -1055,24 +1412,22 @@ async function estimatePrefill(inputChars, modelId) {
|
|
|
1055
1412
|
fit,
|
|
1056
1413
|
};
|
|
1057
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
|
+
}
|
|
1058
1428
|
}
|
|
1059
1429
|
catch {
|
|
1060
|
-
// Sample fetch failed — fall through to
|
|
1061
|
-
}
|
|
1062
|
-
// 2. Ratio fallback — uses aggregate stats already in memory.
|
|
1063
|
-
const stats = lifetime.modelStats.get(modelId);
|
|
1064
|
-
if (stats && stats.ttftCalls >= 2 && stats.totalTtftMs > 0 && stats.totalPromptTokens > 0) {
|
|
1065
|
-
const avgPromptTokens = stats.totalPromptTokens / stats.calls;
|
|
1066
|
-
const avgTtftSec = (stats.totalTtftMs / stats.ttftCalls) / 1000;
|
|
1067
|
-
if (avgTtftSec > 0) {
|
|
1068
|
-
const prefillTokPerSec = avgPromptTokens / avgTtftSec;
|
|
1069
|
-
return {
|
|
1070
|
-
inputTokens,
|
|
1071
|
-
estimatedSeconds: inputTokens / prefillTokPerSec,
|
|
1072
|
-
basis: 'ratio',
|
|
1073
|
-
prefillTokPerSec,
|
|
1074
|
-
};
|
|
1075
|
-
}
|
|
1430
|
+
// Sample fetch failed — fall through to the conservative default.
|
|
1076
1431
|
}
|
|
1077
1432
|
// 3. Conservative default for unknown model/hardware.
|
|
1078
1433
|
return {
|
|
@@ -1151,9 +1506,7 @@ async function routeToModel(taskType, override) {
|
|
|
1151
1506
|
function assessQuality(resp, rawContent) {
|
|
1152
1507
|
const hadThinkBlocks = /<think>/.test(rawContent);
|
|
1153
1508
|
const estimated = !resp.usage && resp.content.length > 0;
|
|
1154
|
-
const tokPerSec = resp
|
|
1155
|
-
? resp.usage.completion_tokens / (resp.generationMs / 1000)
|
|
1156
|
-
: null;
|
|
1509
|
+
const tokPerSec = computeTokPerSec(resp);
|
|
1157
1510
|
return {
|
|
1158
1511
|
truncated: resp.truncated,
|
|
1159
1512
|
prefillStall: resp.prefillStall ?? false,
|
|
@@ -1183,6 +1536,10 @@ function formatQualityLine(quality) {
|
|
|
1183
1536
|
flags.push('tokens-estimated');
|
|
1184
1537
|
if (quality.finishReason === 'length')
|
|
1185
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)');
|
|
1186
1543
|
if (flags.length === 0)
|
|
1187
1544
|
return '';
|
|
1188
1545
|
return `Quality: ${flags.join(', ')}`;
|
|
@@ -1214,6 +1571,12 @@ function formatFooter(resp, extra) {
|
|
|
1214
1571
|
else {
|
|
1215
1572
|
parts.push(`${resp.usage.prompt_tokens}→${resp.usage.completion_tokens} tokens`);
|
|
1216
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
|
+
}
|
|
1217
1580
|
}
|
|
1218
1581
|
else if (resp.content.length > 0) {
|
|
1219
1582
|
// Estimate when usage is missing (truncated responses where final SSE chunk was lost)
|
|
@@ -1225,8 +1588,9 @@ function formatFooter(resp, extra) {
|
|
|
1225
1588
|
if (resp.ttftMs !== undefined)
|
|
1226
1589
|
perfParts.push(`TTFT: ${resp.ttftMs}ms`);
|
|
1227
1590
|
let tokPerSec = 0;
|
|
1228
|
-
|
|
1229
|
-
|
|
1591
|
+
const measuredTokPerSec = computeTokPerSec(resp);
|
|
1592
|
+
if (measuredTokPerSec !== null) {
|
|
1593
|
+
tokPerSec = measuredTokPerSec;
|
|
1230
1594
|
perfParts.push(`${tokPerSec.toFixed(1)} tok/s`);
|
|
1231
1595
|
}
|
|
1232
1596
|
if (resp.generationMs)
|
|
@@ -1240,7 +1604,9 @@ function formatFooter(resp, extra) {
|
|
|
1240
1604
|
const qualityLine = formatQualityLine(quality);
|
|
1241
1605
|
if (qualityLine)
|
|
1242
1606
|
parts.push(qualityLine);
|
|
1243
|
-
if (resp.
|
|
1607
|
+
if (resp.streamError)
|
|
1608
|
+
parts.push(`⚠ UPSTREAM ERROR (partial result — backend reported: ${resp.streamError})`);
|
|
1609
|
+
else if (resp.truncated)
|
|
1244
1610
|
parts.push('⚠ TRUNCATED (soft timeout — partial result)');
|
|
1245
1611
|
const benchmarkLine = isFirstBenchmarkedCall(resp.model, tokPerSec)
|
|
1246
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.`
|
|
@@ -1260,6 +1626,17 @@ function formatFooter(resp, extra) {
|
|
|
1260
1626
|
return lines.join('\n');
|
|
1261
1627
|
}
|
|
1262
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
|
+
};
|
|
1263
1640
|
const TOOLS = [
|
|
1264
1641
|
{
|
|
1265
1642
|
name: 'chat',
|
|
@@ -1277,7 +1654,8 @@ const TOOLS = [
|
|
|
1277
1654
|
'(1) Send COMPLETE context — the local LLM cannot read files.\n' +
|
|
1278
1655
|
'(2) Be explicit about output format ("respond as a JSON array", "return only the function").\n' +
|
|
1279
1656
|
'(3) Specific system persona beats generic — "Senior TypeScript dev" not "helpful assistant".\n' +
|
|
1280
|
-
'(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' +
|
|
1281
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.',
|
|
1282
1660
|
inputSchema: {
|
|
1283
1661
|
type: 'object',
|
|
@@ -1296,7 +1674,7 @@ const TOOLS = [
|
|
|
1296
1674
|
},
|
|
1297
1675
|
max_tokens: {
|
|
1298
1676
|
type: 'number',
|
|
1299
|
-
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.',
|
|
1300
1678
|
},
|
|
1301
1679
|
json_schema: {
|
|
1302
1680
|
type: 'object',
|
|
@@ -1306,6 +1684,7 @@ const TOOLS = [
|
|
|
1306
1684
|
type: 'string',
|
|
1307
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.',
|
|
1308
1686
|
},
|
|
1687
|
+
...SAMPLING_PROPS,
|
|
1309
1688
|
},
|
|
1310
1689
|
required: ['message'],
|
|
1311
1690
|
},
|
|
@@ -1346,7 +1725,7 @@ const TOOLS = [
|
|
|
1346
1725
|
},
|
|
1347
1726
|
max_tokens: {
|
|
1348
1727
|
type: 'number',
|
|
1349
|
-
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.',
|
|
1350
1729
|
},
|
|
1351
1730
|
json_schema: {
|
|
1352
1731
|
type: 'object',
|
|
@@ -1356,6 +1735,7 @@ const TOOLS = [
|
|
|
1356
1735
|
type: 'string',
|
|
1357
1736
|
description: 'Optional: pin to a specific model id. When set, overrides automatic routing.',
|
|
1358
1737
|
},
|
|
1738
|
+
...SAMPLING_PROPS,
|
|
1359
1739
|
},
|
|
1360
1740
|
required: ['instruction'],
|
|
1361
1741
|
},
|
|
@@ -1392,12 +1772,13 @@ const TOOLS = [
|
|
|
1392
1772
|
},
|
|
1393
1773
|
max_tokens: {
|
|
1394
1774
|
type: 'number',
|
|
1395
|
-
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.',
|
|
1396
1776
|
},
|
|
1397
1777
|
model: {
|
|
1398
1778
|
type: 'string',
|
|
1399
1779
|
description: 'Optional: pin to a specific model id. When set, overrides automatic routing.',
|
|
1400
1780
|
},
|
|
1781
|
+
...SAMPLING_PROPS,
|
|
1401
1782
|
},
|
|
1402
1783
|
required: ['code', 'task'],
|
|
1403
1784
|
},
|
|
@@ -1435,12 +1816,13 @@ const TOOLS = [
|
|
|
1435
1816
|
},
|
|
1436
1817
|
max_tokens: {
|
|
1437
1818
|
type: 'number',
|
|
1438
|
-
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.',
|
|
1439
1820
|
},
|
|
1440
1821
|
model: {
|
|
1441
1822
|
type: 'string',
|
|
1442
1823
|
description: 'Optional: pin to a specific model id. When set, overrides automatic routing.',
|
|
1443
1824
|
},
|
|
1825
|
+
...SAMPLING_PROPS,
|
|
1444
1826
|
},
|
|
1445
1827
|
required: ['paths', 'task'],
|
|
1446
1828
|
},
|
|
@@ -1507,7 +1889,7 @@ const SIDEKICK_INSTRUCTIONS = `Houtini-lm is a local LLM sidekick. It runs on th
|
|
|
1507
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` +
|
|
1508
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` +
|
|
1509
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.`;
|
|
1510
|
-
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 });
|
|
1511
1893
|
// ── MCP Resources ─────────────────────────────────────────────────────
|
|
1512
1894
|
// Exposes session performance metrics as a readable resource so Claude can
|
|
1513
1895
|
// proactively check offload efficiency and make smarter delegation decisions.
|
|
@@ -1540,7 +1922,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
1540
1922
|
totalTokensOffloaded: session.promptTokens + session.completionTokens,
|
|
1541
1923
|
},
|
|
1542
1924
|
perModel: modelStats,
|
|
1543
|
-
endpoint: LM_BASE_URL,
|
|
1925
|
+
endpoint: redactUrl(LM_BASE_URL),
|
|
1544
1926
|
};
|
|
1545
1927
|
return {
|
|
1546
1928
|
contents: [{
|
|
@@ -1554,30 +1936,36 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
1554
1936
|
});
|
|
1555
1937
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
1556
1938
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1557
|
-
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 ?? {};
|
|
1558
1944
|
const progressToken = request.params._meta?.progressToken;
|
|
1559
1945
|
try {
|
|
1560
1946
|
switch (name) {
|
|
1561
1947
|
case 'chat': {
|
|
1562
1948
|
const { message, system, temperature, max_tokens, json_schema, model } = args;
|
|
1563
1949
|
const route = await routeToModel('chat', model);
|
|
1950
|
+
const responseFormat = toResponseFormat(json_schema);
|
|
1564
1951
|
const messages = [];
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
+
});
|
|
1571
1961
|
messages.push({ role: 'user', content: message });
|
|
1572
|
-
const responseFormat = json_schema
|
|
1573
|
-
? { type: 'json_schema', json_schema: { name: json_schema.name, strict: json_schema.strict ?? true, schema: json_schema.schema } }
|
|
1574
|
-
: undefined;
|
|
1575
1962
|
const resp = await chatCompletionStreaming(messages, {
|
|
1576
|
-
temperature: temperature ?? route.hints.chatTemp,
|
|
1577
|
-
maxTokens: max_tokens,
|
|
1963
|
+
temperature: validTemperature(temperature) ?? route.hints.chatTemp,
|
|
1964
|
+
maxTokens: validMaxTokens(max_tokens),
|
|
1578
1965
|
model: route.modelId,
|
|
1579
1966
|
responseFormat,
|
|
1580
1967
|
progressToken,
|
|
1968
|
+
sampling: extractSamplingParams(args),
|
|
1581
1969
|
});
|
|
1582
1970
|
const footer = formatFooter(resp);
|
|
1583
1971
|
return { content: [{ type: 'text', text: resp.content + footer }] };
|
|
@@ -1585,12 +1973,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1585
1973
|
case 'custom_prompt': {
|
|
1586
1974
|
const { system, context, instruction, temperature, max_tokens, json_schema, model } = args;
|
|
1587
1975
|
const route = await routeToModel('analysis', model);
|
|
1976
|
+
const responseFormat = toResponseFormat(json_schema);
|
|
1588
1977
|
const messages = [];
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
: (
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
+
});
|
|
1594
1987
|
// Multi-turn format prevents context bleed in smaller models.
|
|
1595
1988
|
// Context goes in a separate user→assistant exchange so the model
|
|
1596
1989
|
// "acknowledges" it before receiving the actual instruction.
|
|
@@ -1599,15 +1992,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1599
1992
|
messages.push({ role: 'assistant', content: 'Understood. I have read the full context. What would you like me to do with it?' });
|
|
1600
1993
|
}
|
|
1601
1994
|
messages.push({ role: 'user', content: instruction });
|
|
1602
|
-
const responseFormat = json_schema
|
|
1603
|
-
? { type: 'json_schema', json_schema: { name: json_schema.name, strict: json_schema.strict ?? true, schema: json_schema.schema } }
|
|
1604
|
-
: undefined;
|
|
1605
1995
|
const resp = await chatCompletionStreaming(messages, {
|
|
1606
|
-
temperature: temperature ?? route.hints.chatTemp,
|
|
1607
|
-
maxTokens: max_tokens,
|
|
1996
|
+
temperature: validTemperature(temperature) ?? route.hints.chatTemp,
|
|
1997
|
+
maxTokens: validMaxTokens(max_tokens),
|
|
1608
1998
|
model: route.modelId,
|
|
1609
1999
|
responseFormat,
|
|
1610
2000
|
progressToken,
|
|
2001
|
+
sampling: extractSamplingParams(args),
|
|
1611
2002
|
});
|
|
1612
2003
|
const footer = formatFooter(resp);
|
|
1613
2004
|
return {
|
|
@@ -1618,15 +2009,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1618
2009
|
const { code, task, language, max_tokens: codeMaxTokens, model } = args;
|
|
1619
2010
|
const lang = language || 'unknown';
|
|
1620
2011
|
const route = await routeToModel('code', model);
|
|
1621
|
-
const outputConstraint = route.hints.outputConstraint
|
|
1622
|
-
? ` ${route.hints.outputConstraint}`
|
|
1623
|
-
: '';
|
|
1624
2012
|
// Task goes in system message so smaller models don't lose it once
|
|
1625
2013
|
// the code block fills the attention window. Code is sole user content.
|
|
1626
2014
|
const codeMessages = [
|
|
1627
2015
|
{
|
|
1628
2016
|
role: 'system',
|
|
1629
|
-
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
|
+
}),
|
|
1630
2022
|
},
|
|
1631
2023
|
{
|
|
1632
2024
|
role: 'user',
|
|
@@ -1635,9 +2027,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1635
2027
|
];
|
|
1636
2028
|
const codeResp = await chatCompletionStreaming(codeMessages, {
|
|
1637
2029
|
temperature: route.hints.codeTemp,
|
|
1638
|
-
|
|
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),
|
|
1639
2035
|
model: route.modelId,
|
|
1640
2036
|
progressToken,
|
|
2037
|
+
sampling: extractSamplingParams(args),
|
|
1641
2038
|
});
|
|
1642
2039
|
const codeFooter = formatFooter(codeResp, lang);
|
|
1643
2040
|
const suggestionLine = route.suggestion ? `\n${route.suggestion}` : '';
|
|
@@ -1662,7 +2059,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1662
2059
|
// Read all files in parallel. One unreadable file doesn't sink the call —
|
|
1663
2060
|
// failures become inline error sections so the model can still reason about
|
|
1664
2061
|
// the rest of the bundle.
|
|
1665
|
-
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) })));
|
|
1666
2063
|
const sections = [];
|
|
1667
2064
|
let successCount = 0;
|
|
1668
2065
|
reads.forEach((r, i) => {
|
|
@@ -1684,9 +2081,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1684
2081
|
}
|
|
1685
2082
|
const lang = language || 'unknown';
|
|
1686
2083
|
const route = await routeToModel('code', model);
|
|
1687
|
-
const outputConstraint = route.hints.outputConstraint
|
|
1688
|
-
? ` ${route.hints.outputConstraint}`
|
|
1689
|
-
: '';
|
|
1690
2084
|
const combined = sections.join('\n\n');
|
|
1691
2085
|
// Pre-flight prefill estimate. Huge inputs can legitimately exceed
|
|
1692
2086
|
// the MCP client's ~60s request timeout during prompt processing, and
|
|
@@ -1701,7 +2095,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1701
2095
|
// avoids the under-prediction a ratio-of-averages produces on inputs
|
|
1702
2096
|
// much larger than the historical mean.
|
|
1703
2097
|
const estimate = await estimatePrefill(combined.length, route.modelId);
|
|
1704
|
-
|
|
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';
|
|
1705
2104
|
if (isConfidentEstimate && estimate.estimatedSeconds > PREFILL_REFUSE_THRESHOLD_SEC) {
|
|
1706
2105
|
const estSec = Math.round(estimate.estimatedSeconds);
|
|
1707
2106
|
const basisLine = estimate.basis === 'linear-fit'
|
|
@@ -1729,7 +2128,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1729
2128
|
const codeMessages = [
|
|
1730
2129
|
{
|
|
1731
2130
|
role: 'system',
|
|
1732
|
-
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
|
+
}),
|
|
1733
2136
|
},
|
|
1734
2137
|
{
|
|
1735
2138
|
role: 'user',
|
|
@@ -1740,9 +2143,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1740
2143
|
// auto-derivation in chatCompletionStreamingInner fires when the caller omits it.
|
|
1741
2144
|
const codeResp = await chatCompletionStreaming(codeMessages, {
|
|
1742
2145
|
temperature: route.hints.codeTemp,
|
|
1743
|
-
maxTokens: codeMaxTokens,
|
|
2146
|
+
maxTokens: validMaxTokens(codeMaxTokens),
|
|
1744
2147
|
model: route.modelId,
|
|
1745
2148
|
progressToken,
|
|
2149
|
+
sampling: extractSamplingParams(args),
|
|
1746
2150
|
});
|
|
1747
2151
|
const readSummary = successCount === paths.length
|
|
1748
2152
|
? `${paths.length} file(s) read`
|
|
@@ -1765,7 +2169,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1765
2169
|
return {
|
|
1766
2170
|
content: [{
|
|
1767
2171
|
type: 'text',
|
|
1768
|
-
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.`,
|
|
1769
2173
|
}],
|
|
1770
2174
|
};
|
|
1771
2175
|
}
|
|
@@ -1774,12 +2178,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1774
2178
|
return {
|
|
1775
2179
|
content: [{
|
|
1776
2180
|
type: 'text',
|
|
1777
|
-
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.`,
|
|
1778
2182
|
}],
|
|
1779
2183
|
};
|
|
1780
2184
|
}
|
|
1781
2185
|
const loaded = models.filter((m) => m.state === 'loaded' || !m.state);
|
|
1782
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
|
+
}
|
|
1783
2202
|
const primary = loaded[0] || models[0];
|
|
1784
2203
|
const ctx = getContextLength(primary);
|
|
1785
2204
|
const primaryProfile = await getModelProfileAsync(primary);
|
|
@@ -1821,7 +2240,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1821
2240
|
: getBackend() === 'ollama' ? 'Ollama'
|
|
1822
2241
|
: 'OpenAI-compatible';
|
|
1823
2242
|
let text = `Status: ONLINE\n` +
|
|
1824
|
-
`Endpoint: ${LM_BASE_URL} (${backendLabel})\n` +
|
|
2243
|
+
`Endpoint: ${redactUrl(LM_BASE_URL)} (${backendLabel})\n` +
|
|
1825
2244
|
`Connection latency: ${ms}ms (does not reflect inference speed)\n` +
|
|
1826
2245
|
`Active model: ${primary.id}\n` +
|
|
1827
2246
|
`Context window: ${ctx.toLocaleString()} tokens\n` +
|
|
@@ -1910,13 +2329,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1910
2329
|
const usageInfo = data.usage
|
|
1911
2330
|
? `${data.usage.prompt_tokens} tokens embedded`
|
|
1912
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)));
|
|
1913
2338
|
return {
|
|
1914
2339
|
content: [{
|
|
1915
2340
|
type: 'text',
|
|
1916
2341
|
text: JSON.stringify({
|
|
1917
2342
|
model: data.model,
|
|
1918
2343
|
dimensions: embedding.length,
|
|
1919
|
-
embedding,
|
|
2344
|
+
embedding: compact,
|
|
1920
2345
|
usage: usageInfo,
|
|
1921
2346
|
}),
|
|
1922
2347
|
}],
|
|
@@ -1931,7 +2356,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1931
2356
|
const lines = [];
|
|
1932
2357
|
lines.push(`## Houtini LM stats`);
|
|
1933
2358
|
lines.push('');
|
|
1934
|
-
lines.push(`**Endpoint**: ${LM_BASE_URL} (${backendLabel})`);
|
|
2359
|
+
lines.push(`**Endpoint**: ${redactUrl(LM_BASE_URL)} (${backendLabel})`);
|
|
1935
2360
|
if (lifetime.firstSeenAt) {
|
|
1936
2361
|
lines.push(`**First call on this workstation**: ${new Date(lifetime.firstSeenAt).toISOString().slice(0, 10)}`);
|
|
1937
2362
|
}
|
|
@@ -2020,7 +2445,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2020
2445
|
async function main() {
|
|
2021
2446
|
const transport = new StdioServerTransport();
|
|
2022
2447
|
await server.connect(transport);
|
|
2023
|
-
process.stderr.write(`Houtini LM server running (${LM_BASE_URL})\n`);
|
|
2448
|
+
process.stderr.write(`Houtini LM server running (${redactUrl(LM_BASE_URL)})\n`);
|
|
2024
2449
|
// Background: profile all available models via HF → SQLite cache
|
|
2025
2450
|
// Non-blocking — server is already accepting requests
|
|
2026
2451
|
listModelsRaw()
|