@otto-code/brain 0.7.5

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.
Files changed (150) hide show
  1. package/bin/otto-brain +2 -0
  2. package/dist/bench/context-corpus.d.ts +58 -0
  3. package/dist/bench/context-corpus.js +210 -0
  4. package/dist/bench/corpus.d.ts +31 -0
  5. package/dist/bench/corpus.js +379 -0
  6. package/dist/bench/curated-repos.d.ts +35 -0
  7. package/dist/bench/curated-repos.js +41 -0
  8. package/dist/bench/health.d.ts +50 -0
  9. package/dist/bench/health.js +112 -0
  10. package/dist/bench/index.d.ts +103 -0
  11. package/dist/bench/index.js +209 -0
  12. package/dist/bench/mine.d.ts +32 -0
  13. package/dist/bench/mine.js +101 -0
  14. package/dist/bench/repo-task.d.ts +93 -0
  15. package/dist/bench/repo-task.js +380 -0
  16. package/dist/bench/repo.d.ts +125 -0
  17. package/dist/bench/repo.js +195 -0
  18. package/dist/bench/rescore.d.ts +41 -0
  19. package/dist/bench/rescore.js +127 -0
  20. package/dist/bench/tasks.d.ts +124 -0
  21. package/dist/bench/tasks.js +1249 -0
  22. package/dist/bench/verify.d.ts +142 -0
  23. package/dist/bench/verify.js +350 -0
  24. package/dist/cli.d.ts +13 -0
  25. package/dist/cli.js +72 -0
  26. package/dist/commands/bench.d.ts +20 -0
  27. package/dist/commands/bench.js +222 -0
  28. package/dist/commands/calibrate.d.ts +19 -0
  29. package/dist/commands/calibrate.js +75 -0
  30. package/dist/commands/catalog.d.ts +32 -0
  31. package/dist/commands/catalog.js +48 -0
  32. package/dist/commands/config.d.ts +13 -0
  33. package/dist/commands/config.js +90 -0
  34. package/dist/commands/lifecycle.d.ts +29 -0
  35. package/dist/commands/lifecycle.js +194 -0
  36. package/dist/commands/pull.d.ts +22 -0
  37. package/dist/commands/pull.js +135 -0
  38. package/dist/commands/report.d.ts +10 -0
  39. package/dist/commands/report.js +34 -0
  40. package/dist/commands/rescore.d.ts +11 -0
  41. package/dist/commands/rescore.js +48 -0
  42. package/dist/commands/runtime.d.ts +21 -0
  43. package/dist/commands/runtime.js +62 -0
  44. package/dist/commands/scan.d.ts +24 -0
  45. package/dist/commands/scan.js +55 -0
  46. package/dist/commands/search.d.ts +31 -0
  47. package/dist/commands/search.js +135 -0
  48. package/dist/commands/share.d.ts +26 -0
  49. package/dist/commands/share.js +148 -0
  50. package/dist/commands/sweep.d.ts +21 -0
  51. package/dist/commands/sweep.js +69 -0
  52. package/dist/commands/ui.d.ts +9 -0
  53. package/dist/commands/ui.js +28 -0
  54. package/dist/config/env.d.ts +10 -0
  55. package/dist/config/env.js +48 -0
  56. package/dist/config/index.d.ts +9 -0
  57. package/dist/config/index.js +9 -0
  58. package/dist/config/otto-home.d.ts +2 -0
  59. package/dist/config/otto-home.js +24 -0
  60. package/dist/config/paths.d.ts +20 -0
  61. package/dist/config/paths.js +33 -0
  62. package/dist/config/private-files.d.ts +3 -0
  63. package/dist/config/private-files.js +32 -0
  64. package/dist/config/profiles.d.ts +34 -0
  65. package/dist/config/profiles.js +116 -0
  66. package/dist/config/schema.d.ts +1035 -0
  67. package/dist/config/schema.js +191 -0
  68. package/dist/config/store.d.ts +11 -0
  69. package/dist/config/store.js +73 -0
  70. package/dist/gguf.d.ts +61 -0
  71. package/dist/gguf.js +231 -0
  72. package/dist/gpu.d.ts +12 -0
  73. package/dist/gpu.js +49 -0
  74. package/dist/index.d.ts +14 -0
  75. package/dist/index.js +13 -0
  76. package/dist/main.d.ts +2 -0
  77. package/dist/main.js +5 -0
  78. package/dist/models/dirs.d.ts +9 -0
  79. package/dist/models/dirs.js +21 -0
  80. package/dist/models/download.d.ts +31 -0
  81. package/dist/models/download.js +105 -0
  82. package/dist/models/enrich.d.ts +36 -0
  83. package/dist/models/enrich.js +68 -0
  84. package/dist/models/hf.d.ts +62 -0
  85. package/dist/models/hf.js +114 -0
  86. package/dist/models/index.d.ts +15 -0
  87. package/dist/models/index.js +49 -0
  88. package/dist/models/manage.d.ts +27 -0
  89. package/dist/models/manage.js +112 -0
  90. package/dist/models/pick.d.ts +3 -0
  91. package/dist/models/pick.js +36 -0
  92. package/dist/models/scan.d.ts +13 -0
  93. package/dist/models/scan.js +185 -0
  94. package/dist/ops/archive.d.ts +34 -0
  95. package/dist/ops/archive.js +105 -0
  96. package/dist/ops/calibrate.d.ts +49 -0
  97. package/dist/ops/calibrate.js +74 -0
  98. package/dist/ops/report.d.ts +26 -0
  99. package/dist/ops/report.js +532 -0
  100. package/dist/ops/results.d.ts +207 -0
  101. package/dist/ops/results.js +250 -0
  102. package/dist/ops/sweep.d.ts +77 -0
  103. package/dist/ops/sweep.js +126 -0
  104. package/dist/output/index.d.ts +5 -0
  105. package/dist/output/index.js +5 -0
  106. package/dist/output/render.d.ts +4 -0
  107. package/dist/output/render.js +96 -0
  108. package/dist/output/types.d.ts +52 -0
  109. package/dist/output/types.js +9 -0
  110. package/dist/output/with-output.d.ts +3 -0
  111. package/dist/output/with-output.js +42 -0
  112. package/dist/run.d.ts +5 -0
  113. package/dist/run.js +19 -0
  114. package/dist/runtime/args.d.ts +21 -0
  115. package/dist/runtime/args.js +71 -0
  116. package/dist/runtime/index.d.ts +13 -0
  117. package/dist/runtime/index.js +41 -0
  118. package/dist/runtime/lmstudio.d.ts +10 -0
  119. package/dist/runtime/lmstudio.js +88 -0
  120. package/dist/runtime/managed.d.ts +26 -0
  121. package/dist/runtime/managed.js +148 -0
  122. package/dist/service/model-selector.d.ts +36 -0
  123. package/dist/service/model-selector.js +123 -0
  124. package/dist/service/pid-lock.d.ts +18 -0
  125. package/dist/service/pid-lock.js +50 -0
  126. package/dist/service/router.d.ts +144 -0
  127. package/dist/service/router.js +746 -0
  128. package/dist/service/scheduler.d.ts +74 -0
  129. package/dist/service/scheduler.js +110 -0
  130. package/dist/service/serve.d.ts +41 -0
  131. package/dist/service/serve.js +278 -0
  132. package/dist/service/supervisor.d.ts +67 -0
  133. package/dist/service/supervisor.js +222 -0
  134. package/dist/service/tailscale.d.ts +19 -0
  135. package/dist/service/tailscale.js +62 -0
  136. package/dist/service/tls.d.ts +77 -0
  137. package/dist/service/tls.js +192 -0
  138. package/dist/sysmon.d.ts +50 -0
  139. package/dist/sysmon.js +103 -0
  140. package/dist/tui/app.d.ts +208 -0
  141. package/dist/tui/app.js +1801 -0
  142. package/dist/tui/screen.d.ts +72 -0
  143. package/dist/tui/screen.js +176 -0
  144. package/dist/types.d.ts +76 -0
  145. package/dist/types.js +7 -0
  146. package/dist/version.d.ts +2 -0
  147. package/dist/version.js +8 -0
  148. package/dist/vram.d.ts +67 -0
  149. package/dist/vram.js +132 -0
  150. package/package.json +58 -0
@@ -0,0 +1,746 @@
1
+ import http from "node:http";
2
+ import { Scheduler } from "./scheduler.js";
3
+ import { makeVramFitPredicate, selectCodingModel } from "./model-selector.js";
4
+ import { query as queryGpu } from "../gpu.js";
5
+ import { rankModels } from "../ops/results.js";
6
+ /**
7
+ * Fronts the supervised llama-server on a stable port.
8
+ *
9
+ * Three jobs beyond plain proxying:
10
+ * 1. While a model is loading or swapping, answer with a clear 503 instead of
11
+ * a connection refused, so clients can retry rather than error out.
12
+ * 2. Watch completions for the failure this whole project exists to prevent:
13
+ * a response whose tokens went entirely into reasoning, leaving no content.
14
+ * 3. Serve `/v1/models` (and LM Studio's `/api/v0/models`) ourselves instead of
15
+ * passing it through. Raw llama-server reports only the loaded model, with
16
+ * its *full file path* as the id and none of the metadata LM Studio clients
17
+ * read (context length, quant, arch, state). We instead list the whole
18
+ * catalog (via the injected `getCatalog`), mark the running one 'loaded',
19
+ * and give each a friendly name plus the context fields clients size against.
20
+ */
21
+ const HOP_BY_HOP = new Set([
22
+ "connection",
23
+ "keep-alive",
24
+ "proxy-authenticate",
25
+ "proxy-authorization",
26
+ "te",
27
+ "trailer",
28
+ "transfer-encoding",
29
+ "upgrade",
30
+ ]);
31
+ const MAX_ANALYSIS_BYTES = 2 * 1024 * 1024;
32
+ // Completion bodies are buffered so the scheduler can read `model` and replay
33
+ // them after a possible model switch. Long-context prompts are large but bounded.
34
+ const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
35
+ const COMPLETION_RE = /\/v1\/(messages|chat\/completions)/;
36
+ export class Telemetry {
37
+ constructor(keep = 50) {
38
+ this.keep = keep;
39
+ this.records = [];
40
+ this.totals = { requests: 0, ok: 0, reasoningOnly: 0, truncated: 0, failed: 0 };
41
+ }
42
+ record(entry) {
43
+ this.totals.requests += 1;
44
+ if (entry.verdict === "reasoning-only")
45
+ this.totals.reasoningOnly += 1;
46
+ else if (entry.verdict === "truncated")
47
+ this.totals.truncated += 1;
48
+ else if (entry.verdict === "failed")
49
+ this.totals.failed += 1;
50
+ else
51
+ this.totals.ok += 1;
52
+ this.records.push(entry);
53
+ if (this.records.length > this.keep)
54
+ this.records.shift();
55
+ }
56
+ /** Advice derived from observed behaviour, not guesswork. */
57
+ get warning() {
58
+ const { requests, reasoningOnly, truncated } = this.totals;
59
+ if (requests < 3)
60
+ return null;
61
+ if (reasoningOnly / requests > 0.3) {
62
+ return `${reasoningOnly}/${requests} responses spent all tokens on reasoning and returned no content - lower the reasoning budget`;
63
+ }
64
+ if (truncated / requests > 0.3) {
65
+ return `${truncated}/${requests} responses hit the token limit - raise the client's max_tokens`;
66
+ }
67
+ return null;
68
+ }
69
+ }
70
+ function isRecord(value) {
71
+ return typeof value === "object" && value !== null;
72
+ }
73
+ function asStringOrNull(value) {
74
+ return typeof value === "string" ? value : null;
75
+ }
76
+ function strlen(value) {
77
+ return String(value || "").length;
78
+ }
79
+ function readTokens(usage, key) {
80
+ if (!isRecord(usage))
81
+ return null;
82
+ const value = usage[key];
83
+ return typeof value === "number" ? value : null;
84
+ }
85
+ function errorMessage(error) {
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+ /** Classify a completion body (Anthropic or OpenAI shaped). */
89
+ export function analyse(bodyText) {
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(bodyText);
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ let finishReason = null;
98
+ let contentChars = 0;
99
+ let reasoningChars = 0;
100
+ let outputTokens = null;
101
+ let toolCalls = 0;
102
+ if (isRecord(parsed) && Array.isArray(parsed.content)) {
103
+ // Anthropic /v1/messages
104
+ finishReason = asStringOrNull(parsed.stop_reason);
105
+ outputTokens = readTokens(parsed.usage, "output_tokens");
106
+ for (const block of parsed.content) {
107
+ if (!isRecord(block))
108
+ continue;
109
+ if (block.type === "text")
110
+ contentChars += strlen(block.text);
111
+ else if (block.type === "thinking")
112
+ reasoningChars += strlen(block.thinking);
113
+ else if (block.type === "tool_use")
114
+ toolCalls += 1;
115
+ }
116
+ }
117
+ else if (isRecord(parsed) && Array.isArray(parsed.choices)) {
118
+ // OpenAI /v1/chat/completions
119
+ const first = parsed.choices[0];
120
+ const choice = isRecord(first) ? first : {};
121
+ finishReason = asStringOrNull(choice.finish_reason);
122
+ outputTokens = readTokens(parsed.usage, "completion_tokens");
123
+ const msg = isRecord(choice.message) ? choice.message : {};
124
+ contentChars = strlen(msg.content);
125
+ reasoningChars = strlen(msg.reasoning_content);
126
+ toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls.length : 0;
127
+ }
128
+ else {
129
+ return null;
130
+ }
131
+ let verdict = "ok";
132
+ if (contentChars === 0 && toolCalls === 0 && reasoningChars > 0)
133
+ verdict = "reasoning-only";
134
+ else if (finishReason === "length" || finishReason === "max_tokens")
135
+ verdict = "truncated";
136
+ return { finishReason, contentChars, reasoningChars, outputTokens, toolCalls, verdict };
137
+ }
138
+ function errorBody(status, message) {
139
+ return JSON.stringify({
140
+ type: "error",
141
+ error: { type: status === 503 ? "overloaded_error" : "api_error", message },
142
+ });
143
+ }
144
+ export function describeModel(model, options = {}) {
145
+ if (!model)
146
+ return null;
147
+ const { state = "not-loaded", profile = null, createdAt = null } = options;
148
+ const md = model.metadata || {};
149
+ const entry = {
150
+ // Standard OpenAI fields — id is the friendly name, never the file path.
151
+ id: model.displayName,
152
+ object: "model",
153
+ created: Math.floor((createdAt ? createdAt.getTime() : Date.now()) / 1000),
154
+ owned_by: model.publisher || "local",
155
+ // LM Studio enrichment.
156
+ type: model.mmprojPath ? "vlm" : "llm",
157
+ publisher: model.publisher || null,
158
+ arch: md.arch || null,
159
+ compatibility_type: "gguf",
160
+ quantization: model.quant || null,
161
+ state,
162
+ max_context_length: md.contextLength ?? null,
163
+ };
164
+ if (state === "loaded" && profile && profile.contextSize) {
165
+ // llama-server splits -c across --parallel slots, so the window a single
166
+ // request actually gets is the total divided by the concurrency.
167
+ const slots = Math.max(1, profile.parallelSlots || 1);
168
+ entry.loaded_context_length = Math.floor(profile.contextSize / slots);
169
+ }
170
+ return entry;
171
+ }
172
+ function resolveCatalog(getCatalog) {
173
+ try {
174
+ return (typeof getCatalog === "function" ? getCatalog() : getCatalog) || [];
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ }
180
+ /**
181
+ * Build the LM Studio-style catalog: every model found on disk, with the one
182
+ * the supervisor is running marked 'loaded'. Falls back to just the running
183
+ * model when no catalog provider is wired in.
184
+ */
185
+ export function buildModelList(supervisor, getCatalog) {
186
+ const loadedId = supervisor.model ? supervisor.model.id : null;
187
+ const stateOf = (model) => {
188
+ if (!loadedId || model.id !== loadedId)
189
+ return "not-loaded";
190
+ if (supervisor.state === "ready")
191
+ return "loaded";
192
+ if (supervisor.state === "starting")
193
+ return "loading";
194
+ return "not-loaded";
195
+ };
196
+ let catalog = resolveCatalog(getCatalog);
197
+ // Guarantee the running model appears even if the snapshot predates it.
198
+ if (supervisor.model && !catalog.some((m) => m.id === loadedId)) {
199
+ catalog = [supervisor.model, ...catalog];
200
+ }
201
+ return catalog.map((model) => describeModel(model, {
202
+ state: stateOf(model),
203
+ profile: model.id === loadedId ? supervisor.profile : null,
204
+ createdAt: model.id === loadedId ? supervisor.startedAt : null,
205
+ }));
206
+ }
207
+ /** Handle the model-discovery endpoints ourselves; returns true if it did. */
208
+ function handleModelsRoute(req, res, supervisor, getCatalog) {
209
+ if (req.method !== "GET")
210
+ return false;
211
+ const url = (req.url || "").split("?")[0];
212
+ const isList = url === "/v1/models" || url === "/api/v0/models";
213
+ const single = url.startsWith("/v1/models/")
214
+ ? decodeURIComponent(url.slice("/v1/models/".length))
215
+ : null;
216
+ if (!isList && single === null)
217
+ return false;
218
+ const list = buildModelList(supervisor, getCatalog);
219
+ let payload;
220
+ if (single !== null) {
221
+ const entry = list.find((e) => e.id === single);
222
+ if (!entry) {
223
+ const body = errorBody(404, `model "${single}" was not found`);
224
+ res.writeHead(404, {
225
+ "content-type": "application/json",
226
+ "content-length": Buffer.byteLength(body),
227
+ });
228
+ res.end(body);
229
+ return true;
230
+ }
231
+ payload = entry;
232
+ }
233
+ else {
234
+ payload = { object: "list", data: list };
235
+ }
236
+ const body = JSON.stringify(payload);
237
+ res.writeHead(200, {
238
+ "content-type": "application/json",
239
+ "content-length": Buffer.byteLength(body),
240
+ });
241
+ res.end(body);
242
+ return true;
243
+ }
244
+ function sendError(res, status, message) {
245
+ if (res.headersSent) {
246
+ if (!res.writableEnded)
247
+ res.destroy();
248
+ return;
249
+ }
250
+ const body = errorBody(status, message);
251
+ const headers = {
252
+ "content-type": "application/json",
253
+ "content-length": Buffer.byteLength(body),
254
+ };
255
+ if (status === 503)
256
+ headers["retry-after"] = "5";
257
+ res.writeHead(status, headers);
258
+ res.end(body);
259
+ }
260
+ /**
261
+ * Forward a buffered completion body to the resident llama-server and stream the
262
+ * reply back, teeing non-streaming bodies for classification. Resolves once the
263
+ * client response is fully concluded (it owns the response in every outcome,
264
+ * including upstream errors), so the scheduler can move to the next turn.
265
+ */
266
+ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, }) {
267
+ return new Promise((resolve) => {
268
+ let settled = false;
269
+ const done = () => {
270
+ if (!settled) {
271
+ settled = true;
272
+ resolve();
273
+ }
274
+ };
275
+ const headers = {};
276
+ for (const [name, value] of Object.entries(req.headers)) {
277
+ if (!HOP_BY_HOP.has(name.toLowerCase()))
278
+ headers[name] = value;
279
+ }
280
+ headers.host = `${supervisor.host}:${supervisor.internalPort}`;
281
+ headers["content-length"] = Buffer.byteLength(body);
282
+ const started = Date.now();
283
+ const upstream = http.request({
284
+ host: supervisor.host,
285
+ port: supervisor.internalPort,
286
+ path: req.url,
287
+ method: req.method,
288
+ headers,
289
+ agent,
290
+ }, (upstreamRes) => {
291
+ const outHeaders = {};
292
+ for (const [name, value] of Object.entries(upstreamRes.headers)) {
293
+ if (!HOP_BY_HOP.has(name.toLowerCase()))
294
+ outHeaders[name] = value;
295
+ }
296
+ res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
297
+ const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
298
+ if (isStream) {
299
+ let sawContent = false;
300
+ let sawReasoning = false;
301
+ upstreamRes.on("data", (chunk) => {
302
+ const text = String(chunk);
303
+ if (text.includes('"text_delta"') || /"content"\s*:\s*"[^"]/.test(text))
304
+ sawContent = true;
305
+ if (text.includes("thinking") || text.includes("reasoning"))
306
+ sawReasoning = true;
307
+ });
308
+ upstreamRes.on("end", () => {
309
+ telemetry.record({
310
+ at: new Date().toISOString(),
311
+ path: req.url,
312
+ ms: Date.now() - started,
313
+ streamed: true,
314
+ verdict: !sawContent && sawReasoning ? "reasoning-only" : "ok",
315
+ });
316
+ done();
317
+ });
318
+ upstreamRes.pipe(res);
319
+ return;
320
+ }
321
+ const chunks = [];
322
+ let size = 0;
323
+ upstreamRes.on("data", (chunk) => {
324
+ if (size < MAX_ANALYSIS_BYTES) {
325
+ chunks.push(chunk);
326
+ size += chunk.length;
327
+ }
328
+ if (!res.writableEnded && !res.destroyed)
329
+ res.write(chunk);
330
+ });
331
+ upstreamRes.on("end", () => {
332
+ if (!res.writableEnded && !res.destroyed)
333
+ res.end();
334
+ const analysis = analyse(Buffer.concat(chunks).toString("utf8"));
335
+ const entry = {
336
+ at: new Date().toISOString(),
337
+ path: req.url,
338
+ ms: Date.now() - started,
339
+ streamed: false,
340
+ ...(analysis ?? { verdict: "ok" }),
341
+ };
342
+ telemetry.record(entry);
343
+ if (entry.verdict === "reasoning-only") {
344
+ logger?.warn?.(`reasoning-only response: ${entry.outputTokens} tokens, ${entry.reasoningChars} reasoning chars, 0 content`);
345
+ }
346
+ done();
347
+ });
348
+ });
349
+ res.on("close", () => {
350
+ if (!res.writableFinished && !upstream.destroyed)
351
+ upstream.destroy();
352
+ });
353
+ res.on("error", () => {
354
+ if (!upstream.destroyed)
355
+ upstream.destroy();
356
+ });
357
+ upstream.on("error", (error) => {
358
+ telemetry.record({
359
+ at: new Date().toISOString(),
360
+ path: req.url,
361
+ verdict: "failed",
362
+ error: error.message,
363
+ });
364
+ sendError(res, 502, `Upstream llama-server error: ${error.message}`);
365
+ done();
366
+ });
367
+ upstream.end(body);
368
+ });
369
+ }
370
+ /**
371
+ * Pure model-admission decision, factored out of the router so it is unit
372
+ * testable. `pinned` is the single model a locked host serves; `resolved` is the
373
+ * normal catalog resolution used when the lock is off. With the lock on, a
374
+ * request naming a model other than the pin is refused (409) rather than queuing
375
+ * a switch; an unnamed request rides the pin.
376
+ */
377
+ export function decideModelGate(params) {
378
+ const { lockModel, requestedName, pinned, resolved } = params;
379
+ if (lockModel) {
380
+ if (!pinned) {
381
+ return { ok: false, status: 503, message: "no model is loaded yet on this locked host" };
382
+ }
383
+ if (requestedName && requestedName !== pinned.id && requestedName !== pinned.displayName) {
384
+ return {
385
+ ok: false,
386
+ status: 409,
387
+ message: `model switching is disabled on this host; only "${pinned.displayName}" is served`,
388
+ };
389
+ }
390
+ return { ok: true, model: pinned };
391
+ }
392
+ if (!resolved) {
393
+ return {
394
+ ok: false,
395
+ status: requestedName ? 404 : 503,
396
+ message: requestedName
397
+ ? `model "${requestedName}" was not found in the catalog`
398
+ : "no model is available to serve",
399
+ };
400
+ }
401
+ return { ok: true, model: resolved };
402
+ }
403
+ /** Buffer a bounded JSON request body (for POST /__host/config). */
404
+ function readJsonBody(req, limit, cb) {
405
+ const chunks = [];
406
+ let size = 0;
407
+ let tooBig = false;
408
+ req.on("data", (chunk) => {
409
+ size += chunk.length;
410
+ if (size > limit)
411
+ tooBig = true;
412
+ else
413
+ chunks.push(chunk);
414
+ });
415
+ req.on("error", () => cb({ ok: false, error: "request stream error" }));
416
+ req.on("end", () => {
417
+ if (tooBig) {
418
+ cb({ ok: false, error: "request body too large" });
419
+ return;
420
+ }
421
+ try {
422
+ const text = Buffer.concat(chunks).toString("utf8") || "{}";
423
+ cb({ ok: true, body: JSON.parse(text) });
424
+ }
425
+ catch {
426
+ cb({ ok: false, error: "invalid JSON body" });
427
+ }
428
+ });
429
+ }
430
+ /** Buffer a completion request, resolve its target model, and queue it. */
431
+ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, scheduler, modelGate, }) {
432
+ const chunks = [];
433
+ let size = 0;
434
+ let tooBig = false;
435
+ req.on("data", (chunk) => {
436
+ size += chunk.length;
437
+ if (size > MAX_REQUEST_BYTES)
438
+ tooBig = true;
439
+ else
440
+ chunks.push(chunk);
441
+ });
442
+ req.on("error", () => {
443
+ /* client vanished before we could queue it */
444
+ });
445
+ req.on("end", () => {
446
+ if (tooBig) {
447
+ sendError(res, 413, "request body too large");
448
+ return;
449
+ }
450
+ const body = Buffer.concat(chunks);
451
+ let modelName = null;
452
+ try {
453
+ const parsed = JSON.parse(body.toString("utf8"));
454
+ modelName = isRecord(parsed) && typeof parsed.model === "string" ? parsed.model : null;
455
+ }
456
+ catch {
457
+ /* leave null */
458
+ }
459
+ const gate = modelGate(modelName);
460
+ if (!gate.ok) {
461
+ sendError(res, gate.status, gate.message);
462
+ return;
463
+ }
464
+ const model = gate.model;
465
+ scheduler
466
+ .submit(model, () => proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body }))
467
+ .catch((error) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`));
468
+ });
469
+ }
470
+ // The bench ranking is read from disk (one JSON per run). A completion request
471
+ // must not pay that IO, and rankings only change when a bench run finishes
472
+ // (rare), so the router caches the ranking and re-reads it at most once per
473
+ // window — the cheap time-based trigger.
474
+ const RANKING_TTL_MS = 60000;
475
+ export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, }) {
476
+ const agent = new http.Agent({ keepAlive: true, maxSockets: 32 });
477
+ const scheduler = loadModel
478
+ ? new Scheduler({ supervisor, loadModel, logger: (m) => logger?.warn?.(m) })
479
+ : null;
480
+ // GPU total VRAM is static hardware, so it is queried once at startup and
481
+ // cached. Absent (no nvidia-smi) or not-yet-resolved leaves the fit predicate
482
+ // undefined, and the selector skips the VRAM filter — mirroring serve.ts.
483
+ let fitPredicate;
484
+ void (async () => {
485
+ try {
486
+ fitPredicate = makeVramFitPredicate(await queryGpuInfo());
487
+ }
488
+ catch {
489
+ /* GPU info absent → skip the fit filter */
490
+ }
491
+ })();
492
+ // TTL-cached bench ranking (see RANKING_TTL_MS above).
493
+ let rankingCache = [];
494
+ let rankingAt = 0;
495
+ const getRanking = () => {
496
+ const now = Date.now();
497
+ if (now - rankingAt < RANKING_TTL_MS && rankingAt !== 0)
498
+ return rankingCache;
499
+ try {
500
+ rankingCache = loadRanking();
501
+ }
502
+ catch {
503
+ /* keep the last good ranking (or the empty default) on a read error */
504
+ }
505
+ rankingAt = now;
506
+ return rankingCache;
507
+ };
508
+ const resolveModel = (name) => {
509
+ const catalog = resolveCatalog(getCatalog);
510
+ if (name) {
511
+ const hit = catalog.find((m) => m.displayName === name || m.id === name);
512
+ if (hit)
513
+ return hit;
514
+ if (supervisor.model &&
515
+ (supervisor.model.displayName === name || supervisor.model.id === name)) {
516
+ return supervisor.model;
517
+ }
518
+ return null;
519
+ }
520
+ // No model named: pick the best-ranked coding model that fits the VRAM
521
+ // budget, instead of blindly serving whatever is loaded. The existing
522
+ // default (loaded model, else catalog[0]) is the fallback when no candidate
523
+ // survives, and the loaded model is a tiebreak so equal-scored picks do not
524
+ // trigger a needless swap.
525
+ const fallback = supervisor.model || catalog[0] || null;
526
+ return selectCodingModel({
527
+ models: catalog,
528
+ ranking: getRanking(),
529
+ fits: fitPredicate,
530
+ preferLoadedId: supervisor.model?.id ?? null,
531
+ fallback,
532
+ });
533
+ };
534
+ // The single model this host will serve when switching is locked: the
535
+ // resident model if one is up, else the configured default resolved through
536
+ // the catalog. Null means nothing is loadable yet.
537
+ const pinnedModel = () => {
538
+ if (supervisor.model)
539
+ return supervisor.model;
540
+ const def = getDefaultModel();
541
+ return def ? resolveModel(def) : null;
542
+ };
543
+ // Decide whether a completion for `name` may run. The lock/default are read
544
+ // live so a POST /__host/config change takes effect without a restart. Only
545
+ // resolve the branch the lock actually uses.
546
+ const modelGate = (name) => {
547
+ const lock = getLockModel();
548
+ return decideModelGate({
549
+ lockModel: lock,
550
+ requestedName: name,
551
+ pinned: lock ? pinnedModel() : null,
552
+ resolved: lock ? null : resolveModel(name),
553
+ });
554
+ };
555
+ const sendJson = (res, payload) => {
556
+ const body = JSON.stringify(payload, null, 2);
557
+ res.writeHead(200, {
558
+ "content-type": "application/json",
559
+ "content-length": Buffer.byteLength(body),
560
+ });
561
+ res.end(body);
562
+ };
563
+ return function handler(req, res) {
564
+ // Host-management read surface (`/__host/*`): the single API both the TUI and
565
+ // Otto's GUI consume, so the two never drift. Status is live; config and
566
+ // evals are point-in-time reads the daemon proxies to its settings UI.
567
+ if (req.url === "/__host/status") {
568
+ sendJson(res, {
569
+ version,
570
+ ...supervisor.status(),
571
+ telemetry: { ...telemetry.totals, warning: telemetry.warning },
572
+ scheduler: scheduler ? scheduler.stats() : null,
573
+ recent: telemetry.records.slice(-10),
574
+ });
575
+ return;
576
+ }
577
+ // Config write: apply an editable patch (model/lock live, the rest persisted).
578
+ // Must precede the GET read below, which matches the same URL for any method.
579
+ // Refused unless the owner opted into remote configuration.
580
+ if (req.method === "POST" && req.url === "/__host/config") {
581
+ if (!applyConfigPatch || !getAllowConfigWrite()) {
582
+ sendError(res, 403, "remote configuration is disabled on this brain");
583
+ return;
584
+ }
585
+ readJsonBody(req, MAX_REQUEST_BYTES, (result) => {
586
+ if (!result.ok) {
587
+ sendError(res, 400, result.error);
588
+ return;
589
+ }
590
+ applyConfigPatch(result.body)
591
+ .then((cfg) => sendJson(res, cfg))
592
+ .catch((err) => sendError(res, 500, `could not apply config: ${errorMessage(err)}`));
593
+ });
594
+ return;
595
+ }
596
+ if (req.url === "/__host/config" && getConfig) {
597
+ sendJson(res, getConfig());
598
+ return;
599
+ }
600
+ if (req.url === "/__host/evals" && getEvals) {
601
+ sendJson(res, getEvals());
602
+ return;
603
+ }
604
+ // Answer model discovery ourselves so ids are real names (not paths), the
605
+ // whole catalog is listed, and each carries LM Studio's context fields.
606
+ if (handleModelsRoute(req, res, supervisor, getCatalog))
607
+ return;
608
+ // With a scheduler wired in, completion requests are queued and served in
609
+ // turns — including loading/switching to the model they ask for — instead
610
+ // of failing when it is not the resident one.
611
+ if (scheduler && COMPLETION_RE.test(req.url || "")) {
612
+ scheduleCompletion({
613
+ req,
614
+ res,
615
+ agent,
616
+ supervisor,
617
+ telemetry,
618
+ logger,
619
+ scheduler,
620
+ modelGate,
621
+ });
622
+ return;
623
+ }
624
+ if (supervisor.state !== "ready") {
625
+ const detail = supervisor.state === "starting"
626
+ ? `The model is still loading (${supervisor.model?.displayName ?? "unknown"}). Retry shortly.`
627
+ : `No model is currently loaded (state: ${supervisor.state}).${supervisor.lastError ? ` Last error: ${supervisor.lastError}` : ""}`;
628
+ const body = errorBody(503, detail);
629
+ res.writeHead(503, {
630
+ "content-type": "application/json",
631
+ "content-length": Buffer.byteLength(body),
632
+ "retry-after": "5",
633
+ });
634
+ res.end(body);
635
+ return;
636
+ }
637
+ const headers = {};
638
+ for (const [name, value] of Object.entries(req.headers)) {
639
+ if (!HOP_BY_HOP.has(name.toLowerCase()))
640
+ headers[name] = value;
641
+ }
642
+ headers.host = `${supervisor.host}:${supervisor.internalPort}`;
643
+ const started = Date.now();
644
+ const upstream = http.request({
645
+ host: supervisor.host,
646
+ port: supervisor.internalPort,
647
+ path: req.url,
648
+ method: req.method,
649
+ headers,
650
+ agent,
651
+ }, (upstreamRes) => {
652
+ const outHeaders = {};
653
+ for (const [name, value] of Object.entries(upstreamRes.headers)) {
654
+ if (!HOP_BY_HOP.has(name.toLowerCase()))
655
+ outHeaders[name] = value;
656
+ }
657
+ res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
658
+ const isCompletion = /\/v1\/(messages|chat\/completions)/.test(req.url || "");
659
+ const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
660
+ if (!isCompletion || isStream) {
661
+ // Streaming: pass through untouched, but note whether any content
662
+ // delta ever arrived so a reasoning-only stream is still visible.
663
+ let sawContent = false;
664
+ let sawReasoning = false;
665
+ if (isCompletion) {
666
+ upstreamRes.on("data", (chunk) => {
667
+ const text = String(chunk);
668
+ if (text.includes('"text_delta"') || /"content"\s*:\s*"[^"]/.test(text))
669
+ sawContent = true;
670
+ if (text.includes("thinking") || text.includes("reasoning"))
671
+ sawReasoning = true;
672
+ });
673
+ upstreamRes.on("end", () => {
674
+ telemetry.record({
675
+ at: new Date().toISOString(),
676
+ path: req.url,
677
+ ms: Date.now() - started,
678
+ streamed: true,
679
+ verdict: !sawContent && sawReasoning ? "reasoning-only" : "ok",
680
+ });
681
+ });
682
+ }
683
+ upstreamRes.pipe(res);
684
+ return;
685
+ }
686
+ // Non-streaming completion: tee the body so we can classify it.
687
+ const chunks = [];
688
+ let size = 0;
689
+ upstreamRes.on("data", (chunk) => {
690
+ if (size < MAX_ANALYSIS_BYTES) {
691
+ chunks.push(chunk);
692
+ size += chunk.length;
693
+ }
694
+ if (!res.writableEnded && !res.destroyed)
695
+ res.write(chunk);
696
+ });
697
+ upstreamRes.on("end", () => {
698
+ if (!res.writableEnded && !res.destroyed)
699
+ res.end();
700
+ const analysis = analyse(Buffer.concat(chunks).toString("utf8"));
701
+ const entry = {
702
+ at: new Date().toISOString(),
703
+ path: req.url,
704
+ ms: Date.now() - started,
705
+ streamed: false,
706
+ ...(analysis ?? { verdict: "ok" }),
707
+ };
708
+ telemetry.record(entry);
709
+ if (entry.verdict === "reasoning-only") {
710
+ logger?.warn(`reasoning-only response: ${entry.outputTokens} tokens, ${entry.reasoningChars} reasoning chars, 0 content`);
711
+ }
712
+ });
713
+ });
714
+ res.on("close", () => {
715
+ if (!res.writableFinished && !upstream.destroyed)
716
+ upstream.destroy();
717
+ });
718
+ // A broken client socket makes the response stream emit 'error'. Without a
719
+ // listener Node rethrows it as uncaught and takes the whole host down, so
720
+ // absorb it and tear the upstream request down.
721
+ res.on("error", () => {
722
+ if (!upstream.destroyed)
723
+ upstream.destroy();
724
+ });
725
+ upstream.on("error", (error) => {
726
+ telemetry.record({
727
+ at: new Date().toISOString(),
728
+ path: req.url,
729
+ verdict: "failed",
730
+ error: error.message,
731
+ });
732
+ if (res.headersSent) {
733
+ res.destroy();
734
+ return;
735
+ }
736
+ const body = errorBody(502, `Upstream llama-server error: ${error.message}`);
737
+ res.writeHead(502, {
738
+ "content-type": "application/json",
739
+ "content-length": Buffer.byteLength(body),
740
+ });
741
+ res.end(body);
742
+ });
743
+ req.pipe(upstream);
744
+ };
745
+ }
746
+ //# sourceMappingURL=router.js.map