# Houtini LM — Developer guide This document covers internals: how the streaming pipeline works, how the reasoning-model handling is wired, how the SQLite cache persists, and how to extend the tool set. For user-facing docs — install, tool descriptions, configuration — see [README.md](./README.md). ## Project layout ``` src/ index.ts MCP server — tool definitions, request handlers, streaming, session + lifetime accounting, routing, provider-profile registry model-cache.ts SQLite (node:sqlite, WAL) — model profiles, thinking- support detection, per-model performance history server.json MCP registry manifest test.mjs Direct-client integration tests (hits /v1 endpoints) test-mcp-e2e.mjs End-to-end MCP harness — spawns the built server over stdio, drives real tool calls, verifies provider paths benchmark.mjs Throughput + savings benchmark shakedown.mjs End-to-end self-test — runs 7 of the 8 tools in sequence (all except stats) SHAKEDOWN.md Canonical test prompt (for running via Claude chat) add-shebang.mjs Post-build — prepends #!/usr/bin/env node to dist/index.js ``` ## Commands ```bash npm run build # tsc + add-shebang npm run dev # tsc --watch npm run shakedown # end-to-end self-test — sanity-check install + benchmark ``` Integration test: ```bash HOUTINI_LM_ENDPOINT_URL=http://host:1234 node test.mjs ``` Strict-mode TypeScript is enabled — the build must pass with zero errors. ## Streaming pipeline (the hot path) `chatCompletionStreamingInner` in `src/index.ts` is where every inference call runs. The moving parts, in order: 1. **`max_tokens` sizing** — if the caller didn't specify, derive 25% of the active model's context window. Fallback `DEFAULT_MAX_TOKENS` (16384) when context size is unknown. 2. **Reasoning-model gate** — branches on the current provider profile (`getProviderProfile().reasoningStyle`): - `'openrouter-field'` — send `reasoning: { exclude: true }`. OpenRouter normalises thinking output across Nemotron / DeepSeek R1 / Qwen3 / Claude-thinking / gpt-oss and returns clean text content only. Budget still inflated 4× (or +2000) because some upstream providers bill reasoning against the cap before filtering. - `'think-blocks'` (local) — call `getThinkingSupport(modelId)`. If true: set `enable_thinking: false` (Qwen3 vendor param), set `reasoning_effort` to a backend-appropriate minimum (`'none'` on LM Studio + Ollama, `'low'` on generic OpenAI-compatible), and inflate `max_tokens` 4× as a safety net for backends that ignore both flags (Gemma 4 and Qwen3 in Ollama both do). Inflation base is `effectiveMaxTokens` (context-aware), not the raw 16k default. 3. **Fetch POST** — `fetchWithTimeout(/v1/chat/completions, body, 30s connect)`. 4. **Prefill keep-alive timer** — `setInterval(PREFILL_KEEPALIVE_MS)` fires a `notifications/progress` every 10s until the first chunk arrives. Without this, long prefill on slow hardware hits the MCP client's ~60s request timeout before any token streams. 5. **Per-chunk read with split timeouts** — `timedRead` uses `PREFILL_TIMEOUT_MS` (180s) while `firstChunkReceived` is false, then switches to `READ_CHUNK_TIMEOUT_MS` (30s) for the rest. Big prefills legitimately take 1–2 min; mid-stream stalls should surface faster. 6. **Delta parsing** — recognises four OpenAI-vendor delta fields: - `delta.content` — visible text, accumulates into `content` - `delta.reasoning_content` — hidden reasoning (DeepSeek R1, Nemotron, LM Studio's "Separate reasoning_content" dev toggle). Accumulated into the `reasoning` buffer. **Must not be discarded** — see the fallback below. - `delta.reasoning` — same channel, Ollama's naming. Captured into the same `reasoning` buffer. Missing this was a long-standing bug: Ollama thinking models (qwen3:4b, deepseek-r1 etc.) appeared broken because all reasoning tokens were dropped on the floor. - `json.usage.completion_tokens_details.reasoning_tokens` — arrives on the final usage chunk when `stream_options.include_usage` is set. 7. **Strip `...`** — in-band reasoning blocks are stripped from `content` after assembly. Three shapes are handled: balanced pairs (GLM Flash), orphan openers (truncated responses), and orphan closers (Ollama Qwen3 streams reasoning directly on the content channel and terminates it with a bare `` before the real answer). 8. **Empty-output safety nets**, tried in order: - `thinkStripFallback` — stripping emptied `content` but raw content had text. Returns raw content with a `think-strip-empty` quality flag. - `reasoningFallback` — `content` was never populated but `reasoning_content` was streamed (the Nemotron/DeepSeek-R1 case). Returns the raw reasoning with a `[No visible output — ...]` preamble and a `reasoning-only` quality flag. The `recordUsage` function then writes session counters, updates the in-memory lifetime mirror, and fires-and-forgets a SQLite write. ## Reasoning-model handling — the full picture Three independent signals nudge a thinking model toward minimal-reasoning output, with the expectation that at least one will land on any given backend: | Signal | How it's sent | Where it works | |---|---|---| | `enable_thinking: false` | Top-level body field | Qwen3 family via LM Studio | | `reasoning_effort` | Top-level body field — backend-mapped value | LM Studio (`'none'`), Ollama (`'none'`), OpenAI & DeepSeek (`'low'`) | | `max_tokens` inflation (4×) | Increases generation budget | Safety net — works even when the model ignores the other two | Detection lives in `model-cache.ts` — `detectThinkingSupport` (HF-chat-template based) and `detectThinkingSupportFromArch` (arch/id regex fallback). The two paths OR their results. Re-applied at read time in `getThinkingSupport` so stale cache entries from before the detection list was broadened still pick up new flags without a manual refresh. Families currently recognised as thinking: Gemma 4, Nemotron, DeepSeek R1, GLM-4, gpt-oss, Qwen3 base (any `qwen3*` except coder/VL/embed variants — the Jinja template defaults `enable_thinking=true`, which Ollama honours regardless of the API flag), Qwen3-thinking, anything tagged `-thinking`. ## Backend detection Probed once on first `listModelsRaw()` call, cached in `detectedBackend: 'lmstudio' | 'ollama' | 'openai-compat' | 'openrouter'` for the session. Inference always uses `/v1/chat/completions` (portable); the backend flag steers enrichment (richer model metadata, reasoning handling, attribution headers, retry policy, diagnostic labelling). Probe order: 1. **OpenRouter short-circuit** — if `LM_BASE_URL` matches `openrouter.ai` or `HOUTINI_LM_PROVIDER=openrouter` is set, skip probes and go straight to `GET /v1/models` (which on OpenRouter already returns `context_length` and `architecture.input_modalities`). 2. `GET /api/v0/models` — LM Studio (richest metadata) 3. `GET /api/tags` — Ollama (native list, mapped to the `ModelInfo` shape) 4. `GET /v1/models` — generic (vLLM, DeepSeek, llama.cpp, any other OpenAI-compatible server) ## Provider profiles `getProviderProfile()` is a small central registry that gates per-backend behaviour. Keeps divergence in one place instead of scattered across the hot path. Today it has flags for four things: | Flag | Local (`lmstudio`/`ollama`/`openai-compat`) | `openrouter` | |---|---|---| | `extraHeaders` | `{}` | `HTTP-Referer`, `X-Title` for attribution | | `serialiseInference` | `true` (single-GPU protection) | `false` (upstream handles parallelism) | | `retryOnRateLimit` | `false` | `true` (jittered backoff, honours `Retry-After`) | | `reasoningStyle` | `'think-blocks'` | `'openrouter-field'` | Detection is URL-based (`openrouter.ai`) with `HOUTINI_LM_PROVIDER=openrouter` as an explicit override for custom domains or self-hosted proxies. The `ProviderProfile` interface lives near the top of `src/index.ts` — add flags only when a real divergence forces one, not speculatively. Three providers is where the abstraction proves its worth; two is still close to "a couple of if-statements." ## SQLite cache — two tables Database path: `~/.houtini-lm/model-cache.db`. Uses `node:sqlite` (Node's built-in SQLite, Node ≥ 22.5) in WAL mode with `busy_timeout` — no third-party native dependency, and multiple houtini-lm processes can share one cache file with real cross-process concurrency (per-row writes, not whole-file snapshots). ### `model_profiles` Populated at startup by `profileModelsAtStartup`. Each model gets looked up on HuggingFace's free API; the card's `config.tokenizer_config.chat_template` drives `emits_think_blocks` / `supports_thinking_toggle`. TTL 7 days, then re-fetched. Columns: `model_id` (PK), `hf_id`, `pipeline_tag`, `architectures`, `license`, `downloads`, `likes`, `library_name`, `family`, `description`, `strengths`, `weaknesses`, `best_for`, `emits_think_blocks`, `supports_thinking_toggle`, `fetched_at`, `source`. ### `model_performance` Accumulated every call. Used by the `stats` tool, `discover`'s lifetime line, and the `code_task_files` pre-flight estimator. Columns: `model_id` (PK), `total_calls`, `ttft_calls`, `total_ttft_ms`, `perf_calls`, `total_tok_per_sec`, `total_prompt_tokens`, `total_completion_tokens`, `total_reasoning_tokens`, `first_seen_at`, `last_used_at`. At server startup, `hydrateLifetimeFromDb()` copies this into the in-memory `lifetime` mirror so footer and `discover` output stay synchronous (no async DB call on the hot path). Writes fan out via `recordPerformance()` fire-and-forget — a DB hiccup can never stall a tool response. Derived stats: - `avgTtftMs = totalTtftMs / ttftCalls` - `avgTokPerSec = totalTokPerSec / perfCalls` - `prefillTokPerSec ≈ (totalPromptTokens / calls) / (avgTtftSec)` — used by `estimatePrefill()` once `ttftCalls >= 2` ## Pre-flight token estimation (code_task_files) Large inputs can still exceed the MCP client's ~60s request timeout during prompt processing even with keep-alive notifications, because not every client honours the `notifications/progress` timeout-reset (MCP spec says "MAY"). The pre-flight estimator uses measured per-model prefill rate from SQLite to refuse the call early with a diagnostic instead of letting it silently hang. Constants in `src/index.ts`: - `PREFILL_REFUSE_THRESHOLD_SEC = 45` — estimated prefill above this is rejected with a structured error - `PREFILL_WARN_THRESHOLD_SEC = 25` — emits a stderr warning but proceeds - `DEFAULT_PREFILL_TOK_PER_SEC = 300` — conservative default when there's no measured data Refusal only fires when `estimate.basis === 'measured'` (needs ≥2 TTFT samples for the model). First-time callers never get refused — we err on the side of letting the call run. ## Session vs lifetime accounting | | Session | Lifetime | |---|---|---| | Scope | Since server process started | Since SQLite DB first created | | Storage | `session` object in memory | `model_performance` rows + `lifetime` mirror | | Survives Claude Desktop restart | No | Yes | | Used by footer `this session: X · lifetime: Y` | Yes | Yes | | Used by `discover` speed line | "session" variant | "lifetime on this workstation" variant | | Used by `stats` tool | Totals + per-model | Totals + per-model | | Feeds `estimatePrefill` | No | Yes (needs persistence for reliability) | ## Adding a new MCP tool 1. Add to the `TOOLS` array in `src/index.ts` — `name`, description, `inputSchema`. Descriptions matter: MCP clients surface them to the model, so specific language nudges Claude toward correct usage. 2. Add a `case '':` branch to the `CallToolRequestSchema` handler. 3. If the tool calls the LLM, use `chatCompletionStreaming` so it benefits from the semaphore, keep-alive, reasoning handling, and footer. 4. Use `formatFooter(resp, extraLabel)` to produce the standard footer; `recordUsage` runs inside it and writes to both session and lifetime mirrors. ## Adding a new backend The portable `/v1/chat/completions` path should work for any OpenAI-compatible server with no changes — set `HOUTINI_LM_ENDPOINT_URL` and `HOUTINI_LM_API_KEY` and you're likely done. Only add code when the provider has concrete divergence from the generic path. When you do need provider-specific behaviour: 1. **Detection** — extend the `Backend` type with the new string literal and teach `listModelsRaw()` to set `detectedBackend` to it. Prefer URL-based auto-detection with an explicit `HOUTINI_LM_PROVIDER` env var as override. 2. **Provider profile** — add a branch to `getProviderProfile()` for the new backend. Set only the flags that genuinely differ. Don't speculate — if you're unsure whether a flag should be on or off, leave it at the default and let the first real usage show you. 3. **Model listing** — if the server has a richer native endpoint worth preferring (like LM Studio's `/api/v0/models`), add a probe branch to `listModelsRaw()` above the generic `/v1/models` fallback. Map the response shape to `ModelInfo` inline. 4. **`reasoning_effort` values** — if the backend accepts a different set of values, update `getReasoningEffortValue()`. An unsupported value is often a 400 error rather than a silent fallback, so prefer omitting the field to sending a guess. 5. **Capabilities metadata mapping** — if the native list format doesn't fit `ModelInfo`, map it in the probe function (see the Ollama `/api/tags` branch for an example). 6. **Smoke-test** — run `test-mcp-e2e.mjs` against the new endpoint and confirm the expected profile path fires by grepping the stderr log. ## Releasing 1. Bump version in **three places**: `package.json`, `server.json`, and the `new Server({name, version})` call in `src/index.ts`. Keep them in sync. 2. Add a CHANGELOG entry under `## [X.Y.Z] - YYYY-MM-DD`. 3. `npm run build` — must pass cleanly. 4. `npm run shakedown` — smoke-test against a live local endpoint. 5. If the release touches provider-profile code, streaming, model routing, or anything in the hot path, also run `test-mcp-e2e.mjs` against **both** a local endpoint (LM Studio / Ollama) and OpenRouter — the profile branches are gated separately. See the testing matrix above. 6. Commit `v{X.Y.Z}: short description` (version-prefixed for releases). 7. Push and merge to `main`. 8. `npm publish` (requires 2FA — run manually, not via automation). The `prepublishOnly` hook runs the build automatically. Use `npm pack --dry-run` to preview the tarball contents before shipping. ## Gotchas - **stdout is sacred.** Anything via `console.log()` will corrupt the MCP stdio transport. Use `process.stderr.write()` for every debug / log output. - **Version in three places** — easy to forget one. `grep -r '"2.10.0"'` to find everything on a bump. - **Windows commit messages** — use HEREDOC syntax for multi-line messages; cmd.exe doesn't like single quotes in `-m`. - **Model loading is slow** (minutes on cold start). Never try to JIT-load a model — MCP has a ~60s timeout. The routing layer suggests better models instead. - **`node:sqlite`, by design.** Node's built-in SQLite gives real cross-process concurrency (WAL) with no third-party native dependency. Don't swap for `better-sqlite3` — node-gyp is a footgun for npm-installable MCP servers. The tradeoff is a Node ≥ 22.5 floor (declared in `engines`). - **`completion_tokens_details.reasoning_tokens`** arrives only on the final usage chunk — you must set `stream_options.include_usage: true` to get it. We do. ## Testing Four independent test harnesses, each with a different scope: - **`test.mjs`** — **direct-client** integration test. Hits the provider's `/v1/*` endpoints without going through the MCP server. Good for regression-checking changes to streaming / parsing and for confirming a new provider speaks OpenAI protocol at all. Honours `HOUTINI_LM_ENDPOINT_URL` / `HOUTINI_LM_API_KEY` (plus legacy `LM_STUDIO_*` / `LM_PASSWORD` / `OPENROUTER_API_KEY`). - **`test-mcp-e2e.mjs`** — **end-to-end MCP** test. Spawns the built server over stdio and drives real tool calls (`list_models`, `chat`, parallel requests, optional `model` pin). This is the one that verifies provider-profile paths fire correctly — grep the stderr log for `OpenRouter model …: reasoning.exclude=true` to confirm the OpenRouter branch took over. Set `PIN_MODEL=` to also test the per-call model override. - **`benchmark.mjs`** — throughput and savings benchmark, ad-hoc. - **`shakedown.mjs`** (`npm run shakedown`) — the canonical self-test. Runs 7 of the 8 tools end-to-end (all except `stats`) and prints a summary table with TTFT, tok/s, token counts, and reasoning-token split per call. Use this to verify an install or post-release. The conversational equivalent lives in [SHAKEDOWN.md](./SHAKEDOWN.md) — paste it into a Claude session with houtini-lm attached and Claude drives the sequence, evaluating output quality along the way. ### Testing matrix for provider changes When you touch provider-profile code, run the E2E harness against **both** a local and a remote endpoint before shipping. The two paths are gated by different profile flags and a local-only test won't catch regressions on the OpenRouter branch (and vice-versa): ```bash # Local (LM Studio / Ollama / anything serialised) HOUTINI_LM_ENDPOINT_URL=http://localhost:1234 node test-mcp-e2e.mjs # Remote (OpenRouter — no semaphore, reasoning.exclude, retry policy) HOUTINI_LM_ENDPOINT_URL=https://openrouter.ai/api \ HOUTINI_LM_API_KEY=sk-or-v1-... \ PIN_MODEL=nvidia/nemotron-3-nano-30b-a3b:free \ node test-mcp-e2e.mjs ``` ## Quality-signal flags reference Flags that can appear on a response footer (`Quality: ...` line): | Flag | Meaning | |---|---| | `TRUNCATED` | Soft timeout or chunk timeout — partial result returned | | `PREFILL-STALL` | Timeout fired before ANY chunk arrived. Input probably too large for this model/hardware. | | `think-blocks-stripped` | Raw content had `` blocks; stripped before returning | | `think-strip-empty` | Stripping emptied the content; returning raw reasoning as fallback | | `reasoning-only` | No `delta.content` at all; returning `delta.reasoning_content` as fallback. Usually means `reasoning_effort` is being ignored — check stderr. | | `tokens-estimated` | `usage` object missing from stream; token count estimated from content length | | `hit-max-tokens` | `finish_reason: 'length'` — generation hit the `max_tokens` cap | Multiple flags can appear on a single response.