#!/usr/bin/env node /** * Houtini LM shakedown — self-test & benchmark * * Runs the 7-step sequence end-to-end against the configured LM Studio / * Ollama / OpenAI-compatible endpoint, captures real TTFT / tok/s / reasoning * split from each call, and prints a markdown summary. This is the canonical * way to verify an install and get an honest read on what the local model * can do on this workstation. * * Usage: * LM_STUDIO_URL=http://host:1234 node shakedown.mjs * # or simply: * npm run shakedown * * The script talks directly to the OpenAI-compatible /v1 endpoint (same * transport houtini-lm uses for every call) so latency here matches what * you'd see through the MCP server. */ import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const BASE = process.env.HOUTINI_LM_ENDPOINT_URL || process.env.LM_STUDIO_URL || 'http://localhost:1234'; const API_KEY = process.env.HOUTINI_LM_API_KEY || process.env.LM_STUDIO_PASSWORD || process.env.LM_PASSWORD || process.env.OPENROUTER_API_KEY || ''; const REPO = dirname(fileURLToPath(import.meta.url)); // Mirrors apiHeaders() in src/index.ts — attach a bearer token when configured. function authHeaders(extra = {}) { const h = { ...extra }; if (API_KEY) h['Authorization'] = `Bearer ${API_KEY}`; return h; } // ── Backend probe ───────────────────────────────────────────────────── // Mirrors the three-endpoint probe in src/index.ts listModelsRaw. async function detectBackend() { // LM Studio /api/v0/models — richest metadata try { const res = await fetch(`${BASE}/api/v0/models`, { headers: authHeaders(), signal: AbortSignal.timeout(5000) }); if (res.ok) { const data = await res.json(); return { backend: 'LM Studio', models: data.data }; } } catch { /* fall through */ } // Ollama /api/tags try { const res = await fetch(`${BASE}/api/tags`, { headers: authHeaders(), signal: AbortSignal.timeout(5000) }); if (res.ok) { const data = await res.json(); if (Array.isArray(data.models)) { return { backend: 'Ollama', models: data.models.map((m) => ({ id: m.name, arch: m.details?.family, quantization: m.details?.quantization_level, state: 'loaded', })), }; } } } catch { /* fall through */ } // Generic /v1/models const res = await fetch(`${BASE}/v1/models`, { headers: authHeaders(), signal: AbortSignal.timeout(5000) }); if (!res.ok) throw new Error(`Cannot reach ${BASE} — HTTP ${res.status}`); const data = await res.json(); return { backend: 'OpenAI-compatible', models: data.data }; } // ── Streaming chat with TTFT / tok/s capture ─────────────────────────── // Shape-matches what the server does internally: SSE streaming, capture // delta.content AND delta.reasoning_content, read usage.completion_tokens_details // when present. function reasoningEffortValue(backend) { if (backend === 'LM Studio' || backend === 'Ollama') return 'none'; return 'low'; } async function streamingChat({ messages, model, backend, temperature = 0.3, maxTokens, responseFormat }) { const body = { model, messages, temperature, max_tokens: maxTokens ?? 16384, max_completion_tokens: maxTokens ?? 16384, stream: true, stream_options: { include_usage: true }, reasoning_effort: reasoningEffortValue(backend), enable_thinking: false, }; if (responseFormat) body.response_format = responseFormat; const start = Date.now(); const res = await fetch(`${BASE}/v1/chat/completions`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), signal: AbortSignal.timeout(300_000), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status}: ${text}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let content = ''; let reasoning = ''; let usage = null; let buffer = ''; let ttftMs; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed === 'data: [DONE]' || !trimmed.startsWith('data: ')) continue; try { const json = JSON.parse(trimmed.slice(6)); const delta = json.choices?.[0]?.delta; if (typeof delta?.reasoning_content === 'string') reasoning += delta.reasoning_content; if (typeof delta?.content === 'string' && delta.content.length > 0) { if (ttftMs === undefined) ttftMs = Date.now() - start; content += delta.content; } if (json.usage) usage = json.usage; } catch { /* skip unparseable */ } } } // Strip ... blocks from content let clean = content.replace(/[\s\S]*?<\/think>\s*/g, ''); clean = clean.replace(/^\s*/, '').trim(); const generationMs = Date.now() - start; const tokPerSec = usage && generationMs > 50 ? usage.completion_tokens / (generationMs / 1000) : null; return { content: clean, rawContent: content, reasoning, usage, ttftMs, generationMs, tokPerSec, }; } // ── Step runner ─────────────────────────────────────────────────────── const results = []; function recordResult(tool, { ok, ttftMs, tokPerSec, promptTokens, completionTokens, reasoningTokens, notes, }) { results.push({ tool, ok, ttftMs, tokPerSec, promptTokens, completionTokens, reasoningTokens, notes }); } function hr(label) { console.log(`\n${'─'.repeat(70)}\n${label}\n${'─'.repeat(70)}`); } // ── Shakedown steps ─────────────────────────────────────────────────── async function main() { console.log(`\n🧪 Houtini LM shakedown`); console.log(` Endpoint: ${BASE}`); // Step 1: discover hr('1. discover — endpoint + active model'); let backend, models; try { const probe = await detectBackend(); backend = probe.backend; models = probe.models; const loaded = models.filter((m) => m.state === 'loaded' || !m.state); const unloaded = models.filter((m) => m.state === 'not-loaded'); console.log(` Backend detected: ${backend}`); console.log(` Loaded models: ${loaded.length}`); console.log(` Downloaded (not loaded): ${unloaded.length}`); if (loaded[0]) { console.log(` Active: ${loaded[0].id}`); if (loaded[0].loaded_context_length) console.log(` Context window: ${loaded[0].loaded_context_length.toLocaleString()}`); } recordResult('discover', { ok: true, notes: `${backend}, ${loaded.length} loaded, ${unloaded.length} available` }); } catch (err) { console.log(` ❌ FAILED: ${err.message}`); recordResult('discover', { ok: false, notes: err.message }); console.log('\n Cannot reach endpoint — aborting shakedown.'); process.exit(1); } const loadedLlm = models.find((m) => (m.state === 'loaded' || !m.state) && m.type !== 'embeddings'); const loadedEmbed = models.find((m) => (m.state === 'loaded' || !m.state) && m.type === 'embeddings'); const chatModel = loadedLlm?.id; if (!chatModel) { console.log('\n No chat-capable model loaded. Load one and re-run.'); process.exit(1); } // Step 2: list_models (summary) hr('2. list_models — loaded vs available'); for (const m of models) { const mark = (m.state === 'loaded' || !m.state) ? '●' : '○'; const meta = [m.type, m.arch, m.quantization].filter(Boolean).join(' · '); console.log(` ${mark} ${m.id}${meta ? ' ' + meta : ''}`); } recordResult('list_models', { ok: true, notes: `${models.length} total` }); // Step 3: chat sanity check hr('3. chat — WebSockets vs SSE sanity check'); try { const resp = await streamingChat({ backend, model: chatModel, temperature: 0.3, maxTokens: 2000, messages: [ { role: 'system', content: 'Technical writer. Terse bullets, no preamble.' }, { role: 'user', content: 'In 3 bullets, what are the main trade-offs between WebSockets and Server-Sent Events?' }, ], }); const reasoningTokens = resp.usage?.completion_tokens_details?.reasoning_tokens ?? 0; console.log(` ${resp.content.slice(0, 300)}${resp.content.length > 300 ? '…' : ''}`); console.log(` TTFT: ${resp.ttftMs}ms · ${resp.tokPerSec?.toFixed(1)} tok/s · ${resp.usage?.prompt_tokens}→${resp.usage?.completion_tokens} tokens${reasoningTokens ? ` (${reasoningTokens} reasoning)` : ''}`); recordResult('chat', { ok: true, ttftMs: resp.ttftMs, tokPerSec: resp.tokPerSec, promptTokens: resp.usage?.prompt_tokens, completionTokens: resp.usage?.completion_tokens, reasoningTokens, notes: resp.content.length > 20 ? 'answered' : 'short answer', }); } catch (err) { console.log(` ❌ FAILED: ${err.message}`); recordResult('chat', { ok: false, notes: err.message }); } // Step 4: custom_prompt — structured JSON review of timedRead hr('4. custom_prompt — structured JSON review (forced schema)'); const timedReadSrc = `async function timedRead( reader: ReadableStreamDefaultReader, timeoutMs: number, ): Promise<{ done: boolean; value?: Uint8Array } | 'timeout'> { let timer: ReturnType; const timeout = new Promise<'timeout'>((resolve) => { timer = setTimeout(() => resolve('timeout'), timeoutMs); }); try { return await Promise.race([reader.read(), timeout]); } finally { clearTimeout(timer!); } }`; try { const resp = await streamingChat({ backend, model: chatModel, temperature: 0.1, maxTokens: 2000, responseFormat: { type: 'json_schema', json_schema: { name: 'review', schema: { type: 'array', items: { type: 'object', properties: { line: { type: 'number' }, severity: { type: 'string', enum: ['low', 'medium', 'high'] }, issue: { type: 'string' }, suggestion: { type: 'string' }, }, required: ['line', 'severity', 'issue', 'suggestion'], additionalProperties: false, }, maxItems: 5, }, }, }, messages: [ { role: 'system', content: 'Senior TypeScript reviewer, focused on error handling and edge cases. No preamble.' }, { role: 'user', content: `Context:\n\`\`\`typescript\n${timedReadSrc}\n\`\`\`\n\nReturn a JSON array of {line, severity, issue, suggestion}. Max 5 items.` }, ], }); let validJson = false; let itemCount = 0; let severitiesValid = false; try { const parsed = JSON.parse(resp.content); validJson = Array.isArray(parsed); itemCount = parsed.length; severitiesValid = parsed.every((p) => ['low', 'medium', 'high'].includes(p.severity)); } catch { /* validJson stays false */ } console.log(` ${resp.content.slice(0, 200)}…`); console.log(` Valid JSON: ${validJson} · Items: ${itemCount} · Severities in enum: ${severitiesValid}`); const reasoningTokens = resp.usage?.completion_tokens_details?.reasoning_tokens ?? 0; console.log(` TTFT: ${resp.ttftMs}ms · ${resp.tokPerSec?.toFixed(1)} tok/s · ${resp.usage?.prompt_tokens}→${resp.usage?.completion_tokens} tokens${reasoningTokens ? ` (${reasoningTokens} reasoning)` : ''}`); recordResult('custom_prompt', { ok: validJson && severitiesValid, ttftMs: resp.ttftMs, tokPerSec: resp.tokPerSec, promptTokens: resp.usage?.prompt_tokens, completionTokens: resp.usage?.completion_tokens, reasoningTokens, notes: validJson ? `${itemCount} valid items` : 'invalid JSON', }); } catch (err) { console.log(` ❌ FAILED: ${err.message}`); recordResult('custom_prompt', { ok: false, notes: err.message }); } // Step 5: code_task — Jest tests for getContextLength hr('5. code_task — Jest test generation'); const getContextLengthSrc = `const FALLBACK_CONTEXT_LENGTH = parseInt(process.env.LM_CONTEXT_WINDOW || '100000', 10); interface ModelInfo { id: string; loaded_context_length?: number; max_context_length?: number; context_length?: number; max_model_len?: number; } export function getContextLength(model: ModelInfo): number { return model.loaded_context_length ?? model.max_context_length ?? model.context_length ?? model.max_model_len ?? FALLBACK_CONTEXT_LENGTH; }`; try { const resp = await streamingChat({ backend, model: chatModel, temperature: 0.2, maxTokens: 2000, messages: [ { role: 'system', content: 'Expert TypeScript developer. Write clean, runnable Jest tests. No preamble. Output only the test code in a single typescript fenced block.' }, { role: 'user', content: `Write exactly 3 Jest tests (happy path, edge case, error path) for this function:\n\n\`\`\`typescript\n${getContextLengthSrc}\n\`\`\`` }, ], }); const looksLikeCode = /describe\s*\(|it\s*\(|test\s*\(|expect\s*\(/.test(resp.content); console.log(` ${resp.content.slice(0, 200)}…`); console.log(` Looks like Jest code: ${looksLikeCode}`); const reasoningTokens = resp.usage?.completion_tokens_details?.reasoning_tokens ?? 0; console.log(` TTFT: ${resp.ttftMs}ms · ${resp.tokPerSec?.toFixed(1)} tok/s · ${resp.usage?.prompt_tokens}→${resp.usage?.completion_tokens} tokens${reasoningTokens ? ` (${reasoningTokens} reasoning)` : ''}`); recordResult('code_task', { ok: looksLikeCode, ttftMs: resp.ttftMs, tokPerSec: resp.tokPerSec, promptTokens: resp.usage?.prompt_tokens, completionTokens: resp.usage?.completion_tokens, reasoningTokens, notes: looksLikeCode ? 'tests generated' : 'no test syntax detected', }); } catch (err) { console.log(` ❌ FAILED: ${err.message}`); recordResult('code_task', { ok: false, notes: err.message }); } // Step 6: code_task_files — multi-file review (reads this repo's test.mjs + benchmark.mjs) hr('6. code_task_files — cross-file review (test.mjs + benchmark.mjs)'); try { const testSrc = await readFile(join(REPO, 'test.mjs'), 'utf8'); const benchSrc = await readFile(join(REPO, 'benchmark.mjs'), 'utf8'); const combined = `=== test.mjs ===\n${testSrc}\n\n=== benchmark.mjs ===\n${benchSrc}`; const estInputTokens = Math.ceil(combined.length / 4); console.log(` Input: ~${estInputTokens.toLocaleString()} tokens across 2 files`); const resp = await streamingChat({ backend, model: chatModel, temperature: 0.2, maxTokens: 3000, messages: [ { role: 'system', content: 'Expert JavaScript reviewer. Cross-reference both files. Be specific — reference filename and line number. Output a terse numbered list, max 7 items. No preamble, no closing summary.' }, { role: 'user', content: `Find any bug, dead code, or naming inconsistency across BOTH files. They target the same server.\n\n\`\`\`javascript\n${combined}\n\`\`\`` }, ], }); const mentionsBothFiles = /test\.mjs/i.test(resp.content) && /benchmark\.mjs/i.test(resp.content); console.log(` ${resp.content.slice(0, 300)}…`); console.log(` Cross-referenced both files: ${mentionsBothFiles}`); const reasoningTokens = resp.usage?.completion_tokens_details?.reasoning_tokens ?? 0; console.log(` TTFT: ${resp.ttftMs}ms · ${resp.tokPerSec?.toFixed(1)} tok/s · ${resp.usage?.prompt_tokens}→${resp.usage?.completion_tokens} tokens${reasoningTokens ? ` (${reasoningTokens} reasoning)` : ''}`); recordResult('code_task_files', { ok: resp.content.length > 100, ttftMs: resp.ttftMs, tokPerSec: resp.tokPerSec, promptTokens: resp.usage?.prompt_tokens, completionTokens: resp.usage?.completion_tokens, reasoningTokens, notes: mentionsBothFiles ? 'cross-referenced' : 'single-file analysis only', }); } catch (err) { console.log(` ❌ FAILED: ${err.message}`); recordResult('code_task_files', { ok: false, notes: err.message }); } // Step 7: embed — graceful if no embedding model loaded hr('7. embed — vector test'); if (!loadedEmbed) { console.log(` ⚠ No embedding model loaded. Load one (e.g. text-embedding-nomic-embed-text-v1.5) and re-run to test this step.`); recordResult('embed', { ok: false, notes: 'no embedding model loaded (expected — skipped gracefully)' }); } else { try { const res = await fetch(`${BASE}/v1/embeddings`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ input: 'Large language models running locally.', model: loadedEmbed.id }), signal: AbortSignal.timeout(30_000), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`); const data = await res.json(); const vec = data.data?.[0]?.embedding ?? data.embedding; const dims = Array.isArray(vec) ? vec.length : 0; console.log(` Model: ${data.model || loadedEmbed.id}`); console.log(` Dimensions: ${dims}`); recordResult('embed', { ok: dims > 0, notes: `${dims}-dim vector` }); } catch (err) { console.log(` ❌ FAILED: ${err.message}`); recordResult('embed', { ok: false, notes: err.message }); } } // ── Summary ────────────────────────────────────────────────────── hr('Summary'); const passed = results.filter((r) => r.ok).length; console.log(`\n ${passed}/${results.length} steps passed on ${backend}, model=${chatModel}\n`); console.log(`| Tool | OK | TTFT (ms) | tok/s | Tokens in→out | Reasoning | Notes`); console.log(`|-------------------|-----|-----------|--------|----------------------|-----------|------`); let totalPrompt = 0, totalCompletion = 0, totalReasoning = 0; for (const r of results) { const ok = r.ok ? '✅' : '❌'; const ttft = r.ttftMs != null ? String(r.ttftMs).padStart(8) : ' —'; const tps = r.tokPerSec != null ? r.tokPerSec.toFixed(1).padStart(6) : ' —'; const tokens = r.promptTokens != null ? `${r.promptTokens}→${r.completionTokens}` : '—'; const reasoning = r.reasoningTokens ? String(r.reasoningTokens) : '—'; console.log(`| ${r.tool.padEnd(17)} | ${ok} | ${ttft} | ${tps} | ${tokens.padEnd(20)} | ${reasoning.padStart(9)} | ${r.notes || ''}`); if (r.promptTokens) totalPrompt += r.promptTokens; if (r.completionTokens) totalCompletion += r.completionTokens; if (r.reasoningTokens) totalReasoning += r.reasoningTokens; } console.log(`\n Tokens offloaded: ${(totalPrompt + totalCompletion).toLocaleString()} (prompt: ${totalPrompt.toLocaleString()}, completion: ${totalCompletion.toLocaleString()}, reasoning: ${totalReasoning.toLocaleString()})`); console.log(` These tokens stayed on the local model and did not touch the Claude quota.\n`); process.exit(passed === results.length ? 0 : 1); } main().catch((err) => { console.error(`\n💥 Shakedown crashed: ${err.message}\n${err.stack || ''}`); process.exit(2); });