@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/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 { readFile } from 'node:fs/promises';
13
- import { isAbsolute, basename } from 'node:path';
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.usage && resp.generationMs > 50
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 55s budget. This semaphore ensures
209
- // only one inference call runs at a time; others wait in line.
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
- // Remote providers (OpenRouter etc.) benefit from parallelism and do
213
- // their own rate-limit handling; serialising here just throttles us.
214
- if (!getProviderProfile().serialiseInference)
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
- const base = 400 * (attempt + 1);
486
- const target = Math.min(Math.max(base, retryAfter ?? 0), 10_000);
487
- const delay = Math.round(target * (0.5 + Math.random())); // 0.5×..1.5×
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
- if (attempt < retries) {
496
- const delay = Math.round(400 * (attempt + 1) * (0.5 + Math.random()));
497
- await new Promise((r) => setTimeout(r, delay));
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
- * Streaming chat completion with soft timeout.
521
- *
522
- * Uses SSE streaming (`stream: true`) so tokens arrive incrementally.
523
- * If we approach the MCP SDK's ~60s timeout (soft limit at 55s), we
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 effectiveMaxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
561
- if (!options.maxTokens) {
785
+ let contextLen;
786
+ {
562
787
  const activeModel = await resolveActive();
563
- if (activeModel) {
564
- const ctx = getContextLength(activeModel);
565
- effectiveMaxTokens = Math.floor(ctx * 0.25);
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
- // Pre-fetch heartbeat keep the client alive while we wait for the
660
- // upstream LLM to return response headers. Cleared once fetch resolves.
661
- const preFetchTimer = setInterval(() => {
662
- const waitedMs = Date.now() - startTime;
663
- sendProgress(`Connecting to model... (${(waitedMs / 1000).toFixed(0)}s)`);
664
- }, PREFILL_KEEPALIVE_MS);
665
- let res;
666
- try {
667
- res = profile.retryOnRateLimit
668
- ? await fetchWithRetry(`${LM_BASE_URL}/v1/chat/completions`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) }, INFERENCE_CONNECT_TIMEOUT_MS, 2)
669
- : await fetchWithTimeout(`${LM_BASE_URL}/v1/chat/completions`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) }, INFERENCE_CONNECT_TIMEOUT_MS);
670
- }
671
- finally {
672
- clearInterval(preFetchTimer);
673
- }
674
- if (!res.ok) {
675
- const text = await res.text().catch(() => '');
676
- throw new Error(`LM Studio API error ${res.status}: ${text}`);
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 (true) {
704
- // Check soft timeout before each read
705
- const elapsed = Date.now() - startTime;
706
- if (elapsed > SOFT_TIMEOUT_MS) {
707
- truncated = true;
708
- process.stderr.write(`[houtini-lm] Soft timeout at ${elapsed}ms, returning ${content.length} chars of partial content\n`);
709
- break;
710
- }
711
- // Split prefill vs mid-stream timeouts. Prefill on slow hardware with
712
- // a 7k-token input can legitimately take 1-2 min; mid-stream stalls
713
- // should surface much faster. Track firstChunkReceived to switch.
714
- const remaining = SOFT_TIMEOUT_MS - elapsed;
715
- const perChunkCeiling = firstChunkReceived ? READ_CHUNK_TIMEOUT_MS : PREFILL_TIMEOUT_MS;
716
- const chunkTimeout = Math.min(perChunkCeiling, remaining);
717
- const result = await timedRead(reader, chunkTimeout);
718
- if (result === 'timeout') {
719
- truncated = true;
720
- prefillStall = !firstChunkReceived;
721
- process.stderr.write(`[houtini-lm] ${prefillStall ? 'Prefill' : 'Mid-stream'} timeout, returning ${content.length} chars of partial content\n`);
722
- break;
723
- }
724
- if (result.done)
725
- break;
726
- if (!firstChunkReceived) {
727
- firstChunkReceived = true;
728
- }
729
- buffer += decoder.decode(result.value, { stream: true });
730
- // Parse SSE lines
731
- const lines = buffer.split('\n');
732
- buffer = lines.pop() || ''; // Keep incomplete line in buffer
733
- for (const line of lines) {
734
- const trimmed = line.trim();
735
- if (!trimmed || trimmed === 'data: [DONE]')
736
- continue;
737
- if (!trimmed.startsWith('data: '))
738
- continue;
739
- try {
740
- const json = JSON.parse(trimmed.slice(6));
741
- if (json.model)
742
- model = json.model;
743
- const delta = json.choices?.[0]?.delta;
744
- // Reasoning channel. Two field names exist in the wild:
745
- // - LM Studio (dev toggle "Separate reasoning_content"), DeepSeek R1,
746
- // Nemotron → delta.reasoning_content
747
- // - Ollama OpenAI-compat (all thinking models) → delta.reasoning
748
- // We capture both so runtime detection and safety-net fallback fire
749
- // regardless of backend. An Ollama qwen3:4b at low max_tokens
750
- // previously looked like a broken model silent empty content —
751
- // because this channel was dropped.
752
- const reasoningChunk = (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0)
753
- ? delta.reasoning_content
754
- : (typeof delta?.reasoning === 'string' && delta.reasoning.length > 0)
755
- ? delta.reasoning
756
- : '';
757
- if (reasoningChunk) {
758
- reasoning += reasoningChunk;
759
- sendProgress(`Thinking... (${reasoning.length} chars of reasoning)`);
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
- if (typeof delta?.content === 'string' && delta.content.length > 0) {
762
- if (ttftMs === undefined)
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
- // Flush remaining buffer the usage chunk often arrives in the final SSE
780
- // message and may not have a trailing newline, leaving it stranded in buffer.
781
- if (buffer.trim()) {
782
- const trimmed = buffer.trim();
783
- if (trimmed.startsWith('data: ') && trimmed !== 'data: [DONE]') {
784
- try {
785
- const json = JSON.parse(trimmed.slice(6));
786
- if (json.model)
787
- model = json.model;
788
- const delta = json.choices?.[0]?.delta;
789
- const finalReasoningChunk = (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0)
790
- ? delta.reasoning_content
791
- : (typeof delta?.reasoning === 'string' && delta.reasoning.length > 0)
792
- ? delta.reasoning
793
- : '';
794
- if (finalReasoningChunk) {
795
- reasoning += finalReasoningChunk;
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
- if (typeof delta?.content === 'string' && delta.content.length > 0) {
798
- if (ttftMs === undefined)
799
- ttftMs = Date.now() - startTime;
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
- finally {
816
- clearInterval(keepAliveTimer);
817
- // Best-effort cancel with a short timeout cancel() can hang if the upstream
818
- // connection is wedged, so we race it against a 500ms timer. This frees the
819
- // underlying socket sooner on abrupt client disconnects without blocking the
820
- // tool response path.
821
- try {
822
- await Promise.race([
823
- reader.cancel().catch(() => { }),
824
- new Promise((resolve) => setTimeout(resolve, 500)),
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
- catch { /* never propagate cleanup errors */ }
828
- try {
829
- reader.releaseLock();
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
- catch { /* already released */ }
832
- }
833
- const generationMs = Date.now() - startTime;
834
- // Strip <think>...</think> reasoning blocks from models that always emit them
835
- // inline on the content channel (e.g. GLM Flash, Ollama Qwen3). Claude doesn't
836
- // need the model's internal reasoning. Handle three shapes:
837
- // 1. Balanced <think>...</think> — GLM Flash, Nemotron normal case
838
- // 2. Orphan opener <think>... (truncated)
839
- // 3. Orphan closer ...</think> Ollama Qwen3 streams reasoning directly
840
- // on the content channel and terminates with a bare </think> before the
841
- // real answer. Strip everything up to and including the first closer.
842
- let cleanContent = content.replace(/<think>[\s\S]*?<\/think>\s*/g, ''); // closed blocks
843
- cleanContent = cleanContent.replace(/^<think>\s*/, ''); // orphaned opening tag
844
- cleanContent = cleanContent.replace(/^[\s\S]*?<\/think>\s*/, ''); // orphaned closing tag
845
- cleanContent = cleanContent.trim();
846
- // Safety nets for empty visible output. Try in order:
847
- // 1. thinkStripFallback: stripping <think> left nothing, but raw content had text
848
- // 2. reasoningFallback: no visible content AT ALL, but reasoning_content was streamed
849
- // (this is the Nemotron/DeepSeek-R1/LM-Studio-dev-toggle case previously
850
- // produced silent empty bodies because reasoning was discarded)
851
- let thinkStripFallback = false;
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
- else if (reasoning.trim()) {
859
- reasoningFallback = true;
860
- cleanContent =
861
- '[No visible output the model spent its entire output budget on reasoning_content before emitting any content. ' +
862
- 'Raw reasoning below so you can see what it was doing:]\n\n' +
863
- reasoning.trim();
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 ratio estimator
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.usage && resp.generationMs > 50
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
- if (resp.usage && resp.generationMs > 50) {
1229
- tokPerSec = resp.usage.completion_tokens / (resp.generationMs / 1000);
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.truncated)
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\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: 'Max response tokens. Defaults to 25% of the loaded model\'s context window (fallback 16,384). Pass a number to cap it tighter for quick answers.',
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: 'Max response tokens. Defaults to 25% of the loaded model\'s context window (fallback 16,384).',
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: 'Max response tokens. Defaults to 25% of the loaded model\'s context window (fallback 16,384).',
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: 'Optional output budget override. Defaults to 25% of the loaded model\'s context window.',
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: '2.13.2' }, { capabilities: { tools: {}, resources: {} }, instructions: SIDEKICK_INSTRUCTIONS });
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, arguments: args } = request.params;
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
- // Inject output constraint into system prompt if the model needs it
1566
- const systemContent = system
1567
- ? (route.hints.outputConstraint ? `${system}\n\n${route.hints.outputConstraint}` : system)
1568
- : (route.hints.outputConstraint || undefined);
1569
- if (systemContent)
1570
- messages.push({ role: 'system', content: systemContent });
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
- const systemContent = system
1590
- ? (route.hints.outputConstraint ? `${system}\n\n${route.hints.outputConstraint}` : system)
1591
- : (route.hints.outputConstraint || undefined);
1592
- if (systemContent)
1593
- messages.push({ role: 'system', content: systemContent });
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: `Expert ${lang} developer. Your task: ${task}\n\nBe specific — reference line numbers, function names, and concrete fixes. Output your analysis as a markdown list.${outputConstraint}`,
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
- maxTokens: codeMaxTokens ?? DEFAULT_MAX_TOKENS,
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 readFile(p, 'utf8') })));
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
- const isConfidentEstimate = estimate.basis === 'linear-fit' || estimate.basis === 'ratio';
2098
+ // A poor fit (low 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: `Expert ${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. Be specific — line numbers, function names, concrete fixes. Output your analysis as a markdown list.${outputConstraint}`,
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()