@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.
- package/bin/otto-brain +2 -0
- package/dist/bench/context-corpus.d.ts +58 -0
- package/dist/bench/context-corpus.js +210 -0
- package/dist/bench/corpus.d.ts +31 -0
- package/dist/bench/corpus.js +379 -0
- package/dist/bench/curated-repos.d.ts +35 -0
- package/dist/bench/curated-repos.js +41 -0
- package/dist/bench/health.d.ts +50 -0
- package/dist/bench/health.js +112 -0
- package/dist/bench/index.d.ts +103 -0
- package/dist/bench/index.js +209 -0
- package/dist/bench/mine.d.ts +32 -0
- package/dist/bench/mine.js +101 -0
- package/dist/bench/repo-task.d.ts +93 -0
- package/dist/bench/repo-task.js +380 -0
- package/dist/bench/repo.d.ts +125 -0
- package/dist/bench/repo.js +195 -0
- package/dist/bench/rescore.d.ts +41 -0
- package/dist/bench/rescore.js +127 -0
- package/dist/bench/tasks.d.ts +124 -0
- package/dist/bench/tasks.js +1249 -0
- package/dist/bench/verify.d.ts +142 -0
- package/dist/bench/verify.js +350 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +72 -0
- package/dist/commands/bench.d.ts +20 -0
- package/dist/commands/bench.js +222 -0
- package/dist/commands/calibrate.d.ts +19 -0
- package/dist/commands/calibrate.js +75 -0
- package/dist/commands/catalog.d.ts +32 -0
- package/dist/commands/catalog.js +48 -0
- package/dist/commands/config.d.ts +13 -0
- package/dist/commands/config.js +90 -0
- package/dist/commands/lifecycle.d.ts +29 -0
- package/dist/commands/lifecycle.js +194 -0
- package/dist/commands/pull.d.ts +22 -0
- package/dist/commands/pull.js +135 -0
- package/dist/commands/report.d.ts +10 -0
- package/dist/commands/report.js +34 -0
- package/dist/commands/rescore.d.ts +11 -0
- package/dist/commands/rescore.js +48 -0
- package/dist/commands/runtime.d.ts +21 -0
- package/dist/commands/runtime.js +62 -0
- package/dist/commands/scan.d.ts +24 -0
- package/dist/commands/scan.js +55 -0
- package/dist/commands/search.d.ts +31 -0
- package/dist/commands/search.js +135 -0
- package/dist/commands/share.d.ts +26 -0
- package/dist/commands/share.js +148 -0
- package/dist/commands/sweep.d.ts +21 -0
- package/dist/commands/sweep.js +69 -0
- package/dist/commands/ui.d.ts +9 -0
- package/dist/commands/ui.js +28 -0
- package/dist/config/env.d.ts +10 -0
- package/dist/config/env.js +48 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.js +9 -0
- package/dist/config/otto-home.d.ts +2 -0
- package/dist/config/otto-home.js +24 -0
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +33 -0
- package/dist/config/private-files.d.ts +3 -0
- package/dist/config/private-files.js +32 -0
- package/dist/config/profiles.d.ts +34 -0
- package/dist/config/profiles.js +116 -0
- package/dist/config/schema.d.ts +1035 -0
- package/dist/config/schema.js +191 -0
- package/dist/config/store.d.ts +11 -0
- package/dist/config/store.js +73 -0
- package/dist/gguf.d.ts +61 -0
- package/dist/gguf.js +231 -0
- package/dist/gpu.d.ts +12 -0
- package/dist/gpu.js +49 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +5 -0
- package/dist/models/dirs.d.ts +9 -0
- package/dist/models/dirs.js +21 -0
- package/dist/models/download.d.ts +31 -0
- package/dist/models/download.js +105 -0
- package/dist/models/enrich.d.ts +36 -0
- package/dist/models/enrich.js +68 -0
- package/dist/models/hf.d.ts +62 -0
- package/dist/models/hf.js +114 -0
- package/dist/models/index.d.ts +15 -0
- package/dist/models/index.js +49 -0
- package/dist/models/manage.d.ts +27 -0
- package/dist/models/manage.js +112 -0
- package/dist/models/pick.d.ts +3 -0
- package/dist/models/pick.js +36 -0
- package/dist/models/scan.d.ts +13 -0
- package/dist/models/scan.js +185 -0
- package/dist/ops/archive.d.ts +34 -0
- package/dist/ops/archive.js +105 -0
- package/dist/ops/calibrate.d.ts +49 -0
- package/dist/ops/calibrate.js +74 -0
- package/dist/ops/report.d.ts +26 -0
- package/dist/ops/report.js +532 -0
- package/dist/ops/results.d.ts +207 -0
- package/dist/ops/results.js +250 -0
- package/dist/ops/sweep.d.ts +77 -0
- package/dist/ops/sweep.js +126 -0
- package/dist/output/index.d.ts +5 -0
- package/dist/output/index.js +5 -0
- package/dist/output/render.d.ts +4 -0
- package/dist/output/render.js +96 -0
- package/dist/output/types.d.ts +52 -0
- package/dist/output/types.js +9 -0
- package/dist/output/with-output.d.ts +3 -0
- package/dist/output/with-output.js +42 -0
- package/dist/run.d.ts +5 -0
- package/dist/run.js +19 -0
- package/dist/runtime/args.d.ts +21 -0
- package/dist/runtime/args.js +71 -0
- package/dist/runtime/index.d.ts +13 -0
- package/dist/runtime/index.js +41 -0
- package/dist/runtime/lmstudio.d.ts +10 -0
- package/dist/runtime/lmstudio.js +88 -0
- package/dist/runtime/managed.d.ts +26 -0
- package/dist/runtime/managed.js +148 -0
- package/dist/service/model-selector.d.ts +36 -0
- package/dist/service/model-selector.js +123 -0
- package/dist/service/pid-lock.d.ts +18 -0
- package/dist/service/pid-lock.js +50 -0
- package/dist/service/router.d.ts +144 -0
- package/dist/service/router.js +746 -0
- package/dist/service/scheduler.d.ts +74 -0
- package/dist/service/scheduler.js +110 -0
- package/dist/service/serve.d.ts +41 -0
- package/dist/service/serve.js +278 -0
- package/dist/service/supervisor.d.ts +67 -0
- package/dist/service/supervisor.js +222 -0
- package/dist/service/tailscale.d.ts +19 -0
- package/dist/service/tailscale.js +62 -0
- package/dist/service/tls.d.ts +77 -0
- package/dist/service/tls.js +192 -0
- package/dist/sysmon.d.ts +50 -0
- package/dist/sysmon.js +103 -0
- package/dist/tui/app.d.ts +208 -0
- package/dist/tui/app.js +1801 -0
- package/dist/tui/screen.d.ts +72 -0
- package/dist/tui/screen.js +176 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +7 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +8 -0
- package/dist/vram.d.ts +67 -0
- package/dist/vram.js +132 -0
- 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
|