@otto-code/brain 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/lifecycle.js +16 -2
- package/dist/config/schema.d.ts +12 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/store.js +24 -2
- package/dist/models/download.js +15 -5
- package/dist/models/enrich.js +1 -0
- package/dist/service/activity.d.ts +22 -2
- package/dist/service/activity.js +109 -23
- package/dist/service/host-api.d.ts +26 -0
- package/dist/service/host-api.js +88 -1
- package/dist/service/router.d.ts +19 -4
- package/dist/service/router.js +91 -39
- package/dist/service/scheduler.d.ts +9 -1
- package/dist/service/scheduler.js +15 -3
- package/dist/service/serve.d.ts +2 -1
- package/dist/service/serve.js +63 -24
- package/dist/service/status-events.d.ts +88 -0
- package/dist/service/status-events.js +256 -0
- package/dist/service/supervisor.d.ts +2 -2
- package/dist/service/supervisor.js +11 -5
- package/dist/sysmon.d.ts +1 -1
- package/dist/sysmon.js +43 -8
- package/dist/types.d.ts +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brain's authoritative status event source.
|
|
3
|
+
*
|
|
4
|
+
* The brain owns its own state, so it publishes it rather than being polled for
|
|
5
|
+
* it. One daemon subscribes over SSE (`GET /__host/events`) and fans the result
|
|
6
|
+
* out to every connected Otto client, replacing a per-client status poll that
|
|
7
|
+
* could never show a transition sooner than its own interval.
|
|
8
|
+
*
|
|
9
|
+
* Three properties this has to keep:
|
|
10
|
+
*
|
|
11
|
+
* - **Complete snapshots, never deltas.** A reader that missed an event, or
|
|
12
|
+
* reconnected after one, must not have to reconstruct anything: the newest
|
|
13
|
+
* snapshot is the whole truth. That is what makes reconnect repair on both
|
|
14
|
+
* sides idempotent.
|
|
15
|
+
* - **Coalescing lives here, not in the reader.** A timer tick that finds
|
|
16
|
+
* nothing changed emits nothing, and a completion's traffic counters are not
|
|
17
|
+
* a state change. Otherwise "push" would just be a poll with extra steps, and
|
|
18
|
+
* a busy brain would broadcast to every client for every token.
|
|
19
|
+
* - **No work while nobody is listening.** The sampler only runs while a
|
|
20
|
+
* subscriber is attached, so a brain the daemon never subscribed to (an older
|
|
21
|
+
* daemon, or one that is not running) pays nothing.
|
|
22
|
+
*
|
|
23
|
+
* The snapshot itself is deliberately the *cheap* status: no `resources`, whose
|
|
24
|
+
* collection spawns `nvidia-smi`. That stays an opt-in pull for the Overview tab.
|
|
25
|
+
*/
|
|
26
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
27
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
28
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
29
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
30
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
31
|
+
};
|
|
32
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
33
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
34
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
35
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
36
|
+
};
|
|
37
|
+
var _BrainStatusPublisher_instances, _BrainStatusPublisher_listeners, _BrainStatusPublisher_closers, _BrainStatusPublisher_sampleIntervalMs, _BrainStatusPublisher_source, _BrainStatusPublisher_timer, _BrainStatusPublisher_last, _BrainStatusPublisher_lastKey, _BrainStatusPublisher_sampling, _BrainStatusPublisher_resample, _BrainStatusPublisher_closed, _BrainStatusPublisher_sample, _BrainStatusPublisher_startTimer, _BrainStatusPublisher_stopTimer;
|
|
38
|
+
/**
|
|
39
|
+
* How often the publisher resamples while at least one listener is attached.
|
|
40
|
+
*
|
|
41
|
+
* A sample is needed at all because two inputs have no event to hang off:
|
|
42
|
+
* `activity` is a file written by a *different* process (a `calibrate` or
|
|
43
|
+
* `pull` CLI run), and the slot phase split is a loopback read of
|
|
44
|
+
* llama-server's `/slots`. Everything else notifies directly. Sampling is not
|
|
45
|
+
* a heartbeat: an unchanged sample emits nothing.
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_SAMPLE_INTERVAL_MS = 250;
|
|
48
|
+
function isRecord(value) {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The fields whose change is worth waking every connected client for.
|
|
53
|
+
*
|
|
54
|
+
* Everything omitted here still rides in the payload; it just cannot *trigger*
|
|
55
|
+
* a payload on its own. The omissions are the churning ones:
|
|
56
|
+
*
|
|
57
|
+
* - `telemetry` totals and `recent` advance on every completion.
|
|
58
|
+
* - `logLineCount` advances on every llama-server log line.
|
|
59
|
+
* - `slots.contexts` is capacity detail that changes independently of work.
|
|
60
|
+
*
|
|
61
|
+
* `slots.threads` is intentionally included. It is sampled at a bounded 4 Hz,
|
|
62
|
+
* not notified per model token, so it gives the Overview live counts and rates
|
|
63
|
+
* without turning a fast model into a token-rate broadcast.
|
|
64
|
+
*
|
|
65
|
+
* `telemetry.warning` is kept: the reasoning-only advice is a state the UI
|
|
66
|
+
* shows, not a counter.
|
|
67
|
+
*/
|
|
68
|
+
export function statusChangeKey(snapshot) {
|
|
69
|
+
const scheduler = isRecord(snapshot.scheduler) ? snapshot.scheduler : null;
|
|
70
|
+
const telemetry = isRecord(snapshot.telemetry) ? snapshot.telemetry : null;
|
|
71
|
+
const slots = isRecord(snapshot.slots) ? snapshot.slots : null;
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
version: snapshot.version ?? null,
|
|
74
|
+
apiVersion: snapshot.apiVersion ?? null,
|
|
75
|
+
state: snapshot.state ?? null,
|
|
76
|
+
model: snapshot.model ?? null,
|
|
77
|
+
modelId: snapshot.modelId ?? null,
|
|
78
|
+
pid: snapshot.pid ?? null,
|
|
79
|
+
vramBytes: snapshot.vramBytes ?? null,
|
|
80
|
+
loadSeconds: snapshot.loadSeconds ?? null,
|
|
81
|
+
startedAt: snapshot.startedAt ?? null,
|
|
82
|
+
lastError: snapshot.lastError ?? null,
|
|
83
|
+
upstream: snapshot.upstream ?? null,
|
|
84
|
+
runtime: snapshot.runtime ?? null,
|
|
85
|
+
capabilities: snapshot.capabilities ?? null,
|
|
86
|
+
activity: snapshot.activity ?? null,
|
|
87
|
+
reasoning: snapshot.reasoning ?? null,
|
|
88
|
+
inference: snapshot.inference ?? null,
|
|
89
|
+
queued: snapshot.queued ?? null,
|
|
90
|
+
schedulerWaiting: scheduler?.waiting ?? null,
|
|
91
|
+
schedulerLastTurn: scheduler?.lastTurn ?? null,
|
|
92
|
+
warning: telemetry?.warning ?? null,
|
|
93
|
+
slots: slots
|
|
94
|
+
? {
|
|
95
|
+
total: slots.total ?? null,
|
|
96
|
+
busy: slots.busy ?? null,
|
|
97
|
+
idle: slots.idle ?? null,
|
|
98
|
+
prefill: slots.prefill ?? null,
|
|
99
|
+
decode: slots.decode ?? null,
|
|
100
|
+
threads: slots.threads ?? null,
|
|
101
|
+
}
|
|
102
|
+
: null,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Owns the current snapshot and decides when it changed.
|
|
107
|
+
*
|
|
108
|
+
* Constructed before the router (so `serve.ts` can hand the same instance to
|
|
109
|
+
* both the router and the management API) and inert until the router installs
|
|
110
|
+
* the snapshot source with {@link setSource}. `/__host/capabilities` advertises
|
|
111
|
+
* `events` only once it is {@link ready}, so a brain can never claim a stream it
|
|
112
|
+
* cannot actually serve.
|
|
113
|
+
*/
|
|
114
|
+
export class BrainStatusPublisher {
|
|
115
|
+
constructor(options = {}) {
|
|
116
|
+
_BrainStatusPublisher_instances.add(this);
|
|
117
|
+
_BrainStatusPublisher_listeners.set(this, new Set());
|
|
118
|
+
/** Per-subscription teardown, so `close()` can end the responses it feeds. */
|
|
119
|
+
_BrainStatusPublisher_closers.set(this, new Set());
|
|
120
|
+
_BrainStatusPublisher_sampleIntervalMs.set(this, void 0);
|
|
121
|
+
_BrainStatusPublisher_source.set(this, null);
|
|
122
|
+
_BrainStatusPublisher_timer.set(this, null);
|
|
123
|
+
/** The last snapshot that was actually emitted, replayed to a late subscriber. */
|
|
124
|
+
_BrainStatusPublisher_last.set(this, null);
|
|
125
|
+
_BrainStatusPublisher_lastKey.set(this, null);
|
|
126
|
+
/** True while a sample is in flight, so overlapping triggers collapse into one. */
|
|
127
|
+
_BrainStatusPublisher_sampling.set(this, false);
|
|
128
|
+
/** A trigger that arrived mid-sample; runs exactly one more sample afterwards. */
|
|
129
|
+
_BrainStatusPublisher_resample.set(this, false);
|
|
130
|
+
_BrainStatusPublisher_closed.set(this, false);
|
|
131
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_sampleIntervalMs, options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS, "f");
|
|
132
|
+
}
|
|
133
|
+
/** Whether a snapshot source has been installed - i.e. events can be served. */
|
|
134
|
+
get ready() {
|
|
135
|
+
return __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f") !== null && !__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f");
|
|
136
|
+
}
|
|
137
|
+
get listenerCount() {
|
|
138
|
+
return __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size;
|
|
139
|
+
}
|
|
140
|
+
/** Install the snapshot builder. Called once, by the router. */
|
|
141
|
+
setSource(source) {
|
|
142
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_source, source, "f");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Attach a listener. It receives the current snapshot immediately when one is
|
|
146
|
+
* known, and a fresh sample is taken right away so a subscriber that arrives
|
|
147
|
+
* during a transition is not handed a stale first frame.
|
|
148
|
+
*/
|
|
149
|
+
subscribe(listener, onClose) {
|
|
150
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f")) {
|
|
151
|
+
onClose?.();
|
|
152
|
+
return () => { };
|
|
153
|
+
}
|
|
154
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").add(listener);
|
|
155
|
+
if (onClose)
|
|
156
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").add(onClose);
|
|
157
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"))
|
|
158
|
+
listener(__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"));
|
|
159
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_startTimer).call(this);
|
|
160
|
+
this.notify();
|
|
161
|
+
return () => {
|
|
162
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").delete(listener);
|
|
163
|
+
if (onClose)
|
|
164
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").delete(onClose);
|
|
165
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
|
|
166
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Something authoritative changed - resample now rather than at the next tick.
|
|
171
|
+
* Cheap to over-call: an unchanged snapshot emits nothing.
|
|
172
|
+
*/
|
|
173
|
+
notify() {
|
|
174
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
|
|
175
|
+
return;
|
|
176
|
+
void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Drop every listener and end the streams behind them.
|
|
180
|
+
*
|
|
181
|
+
* Ending them is not optional at shutdown: an open SSE response is an open
|
|
182
|
+
* connection, and `server.close()` waits for those, so a brain that only
|
|
183
|
+
* unsubscribed would hang on stop until its daemon happened to disconnect.
|
|
184
|
+
*/
|
|
185
|
+
close() {
|
|
186
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
187
|
+
return;
|
|
188
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_closed, true, "f");
|
|
189
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
|
|
190
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").clear();
|
|
191
|
+
const closers = [...__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f")];
|
|
192
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").clear();
|
|
193
|
+
for (const onClose of closers) {
|
|
194
|
+
try {
|
|
195
|
+
onClose();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Shutdown must not be blocked by one stream failing to end.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
_BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers = new WeakMap(), _BrainStatusPublisher_sampleIntervalMs = new WeakMap(), _BrainStatusPublisher_source = new WeakMap(), _BrainStatusPublisher_timer = new WeakMap(), _BrainStatusPublisher_last = new WeakMap(), _BrainStatusPublisher_lastKey = new WeakMap(), _BrainStatusPublisher_sampling = new WeakMap(), _BrainStatusPublisher_resample = new WeakMap(), _BrainStatusPublisher_closed = new WeakMap(), _BrainStatusPublisher_instances = new WeakSet(), _BrainStatusPublisher_sample = async function _BrainStatusPublisher_sample() {
|
|
204
|
+
const source = __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f");
|
|
205
|
+
if (!source || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
206
|
+
return;
|
|
207
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_sampling, "f")) {
|
|
208
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_resample, true, "f");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_sampling, true, "f");
|
|
212
|
+
try {
|
|
213
|
+
const snapshot = await source();
|
|
214
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
215
|
+
return;
|
|
216
|
+
const key = statusChangeKey(snapshot);
|
|
217
|
+
// Always keep the newest body for replay, even when nothing significant
|
|
218
|
+
// changed: a late subscriber should not get last minute's counters.
|
|
219
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_last, snapshot, "f");
|
|
220
|
+
if (key === __classPrivateFieldGet(this, _BrainStatusPublisher_lastKey, "f"))
|
|
221
|
+
return;
|
|
222
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_lastKey, key, "f");
|
|
223
|
+
// Set iteration tolerates a listener unsubscribing mid-emit, which the
|
|
224
|
+
// SSE teardown does when a daemon disconnects during a snapshot.
|
|
225
|
+
for (const listener of __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f")) {
|
|
226
|
+
try {
|
|
227
|
+
listener(snapshot);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// A broken listener is that listener's problem; the others still get it.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// A failed sample is not a state change. The next tick tries again.
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_sampling, false, "f");
|
|
239
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_resample, "f")) {
|
|
240
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_resample, false, "f");
|
|
241
|
+
void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}, _BrainStatusPublisher_startTimer = function _BrainStatusPublisher_startTimer() {
|
|
245
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
246
|
+
return;
|
|
247
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_timer, setInterval(() => this.notify(), __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f")), "f");
|
|
248
|
+
// Never hold the process open for status reporting alone.
|
|
249
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f").unref?.();
|
|
250
|
+
}, _BrainStatusPublisher_stopTimer = function _BrainStatusPublisher_stopTimer() {
|
|
251
|
+
if (!__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"))
|
|
252
|
+
return;
|
|
253
|
+
clearInterval(__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"));
|
|
254
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_timer, null, "f");
|
|
255
|
+
};
|
|
256
|
+
//# sourceMappingURL=status-events.js.map
|
|
@@ -15,7 +15,7 @@ import type { Profile } from "../config/schema.js";
|
|
|
15
15
|
export declare const DEFAULT_INTERNAL_PORT = 20800;
|
|
16
16
|
export type SupervisorState = "stopped" | "starting" | "ready" | "failed" | "stopping";
|
|
17
17
|
export interface SupervisorOptions {
|
|
18
|
-
runtime: Runtime;
|
|
18
|
+
runtime: Runtime | null;
|
|
19
19
|
internalPort?: number;
|
|
20
20
|
host?: string;
|
|
21
21
|
readyTimeoutMs?: number;
|
|
@@ -40,7 +40,7 @@ export interface SupervisorStatus {
|
|
|
40
40
|
*/
|
|
41
41
|
export declare class Supervisor extends EventEmitter {
|
|
42
42
|
#private;
|
|
43
|
-
runtime: Runtime;
|
|
43
|
+
runtime: Runtime | null;
|
|
44
44
|
internalPort: number;
|
|
45
45
|
host: string;
|
|
46
46
|
readyTimeoutMs: number;
|
|
@@ -54,6 +54,12 @@ export class Supervisor extends EventEmitter {
|
|
|
54
54
|
/** Start (or restart) the server for a model + profile. */
|
|
55
55
|
async start(model, profile) {
|
|
56
56
|
await this.stop();
|
|
57
|
+
if (!this.runtime) {
|
|
58
|
+
this.lastError = "no llama.cpp runtime available";
|
|
59
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
|
|
60
|
+
throw new Error(this.lastError);
|
|
61
|
+
}
|
|
62
|
+
const runtime = this.runtime;
|
|
57
63
|
this.model = model;
|
|
58
64
|
this.profile = profile;
|
|
59
65
|
this.lastError = null;
|
|
@@ -62,12 +68,12 @@ export class Supervisor extends EventEmitter {
|
|
|
62
68
|
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
|
|
63
69
|
const args = buildArgs({ ...profile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host });
|
|
64
70
|
this.args = args;
|
|
65
|
-
this.command = formatCommand(
|
|
71
|
+
this.command = formatCommand(runtime, args);
|
|
66
72
|
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
|
|
67
73
|
const started = Date.now();
|
|
68
|
-
this.child = spawn(
|
|
69
|
-
cwd:
|
|
70
|
-
env: buildEnv(
|
|
74
|
+
this.child = spawn(runtime.exe, args, {
|
|
75
|
+
cwd: runtime.dir,
|
|
76
|
+
env: buildEnv(runtime),
|
|
71
77
|
windowsHide: true,
|
|
72
78
|
stdio: ["ignore", "pipe", "pipe"],
|
|
73
79
|
});
|
|
@@ -194,7 +200,7 @@ export class Supervisor extends EventEmitter {
|
|
|
194
200
|
startedAt: this.startedAt ? this.startedAt.toISOString() : null,
|
|
195
201
|
lastError: this.lastError,
|
|
196
202
|
upstream: this.upstreamBase,
|
|
197
|
-
runtime: `${this.runtime.label} v${this.runtime.version}
|
|
203
|
+
runtime: this.runtime ? `${this.runtime.label} v${this.runtime.version}` : "not installed",
|
|
198
204
|
};
|
|
199
205
|
}
|
|
200
206
|
}
|
package/dist/sysmon.d.ts
CHANGED
package/dist/sysmon.js
CHANGED
|
@@ -72,6 +72,22 @@ function decodedTokens(rec) {
|
|
|
72
72
|
if (typeof value === "number" && Number.isFinite(value))
|
|
73
73
|
return value;
|
|
74
74
|
}
|
|
75
|
+
// Current llama.cpp nests the counter in `next_token`; some speculative
|
|
76
|
+
// builds expose an array there. Older Otto builds only read the top level,
|
|
77
|
+
// which made every current slot look like decode with a made-up count of 1.
|
|
78
|
+
const nextToken = rec.next_token;
|
|
79
|
+
const records = Array.isArray(nextToken) ? nextToken : [nextToken];
|
|
80
|
+
let nested = null;
|
|
81
|
+
for (const value of records) {
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
83
|
+
continue;
|
|
84
|
+
const count = value.n_decoded;
|
|
85
|
+
if (typeof count === "number" && Number.isFinite(count)) {
|
|
86
|
+
nested = nested === null ? count : Math.max(nested, count);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (nested !== null)
|
|
90
|
+
return nested;
|
|
75
91
|
// No counter at all: report a non-zero so the slot lands in decode rather than
|
|
76
92
|
// claiming a prefill that may never have been happening.
|
|
77
93
|
return 1;
|
|
@@ -110,21 +126,40 @@ export class SlotActivityTracker {
|
|
|
110
126
|
_SlotActivityTracker_previous.set(this, new Map());
|
|
111
127
|
}
|
|
112
128
|
sample(rows, now = Date.now()) {
|
|
113
|
-
const threads = rows.flatMap((row,
|
|
129
|
+
const threads = rows.flatMap((row, index) => {
|
|
114
130
|
const record = row;
|
|
131
|
+
const id = record.id;
|
|
132
|
+
const slot = typeof id === "number" && Number.isFinite(id) ? id : index;
|
|
115
133
|
if (!isProcessing(record)) {
|
|
116
134
|
__classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").delete(slot);
|
|
117
135
|
return [];
|
|
118
136
|
}
|
|
119
|
-
const promptTokens =
|
|
137
|
+
const promptTokens = optionalCounter(record, ["n_past", "n_prompt_tokens_processed"]);
|
|
120
138
|
const generatedTokens = decodedTokens(record);
|
|
121
|
-
const
|
|
122
|
-
|
|
139
|
+
const task = typeof record.id_task === "string" || typeof record.id_task === "number"
|
|
140
|
+
? record.id_task
|
|
141
|
+
: null;
|
|
142
|
+
// A reused slot starts a new measurement window. Comparing the new
|
|
143
|
+
// request's counters with the old request is how negative or absurd TPS
|
|
144
|
+
// flashes appeared at action boundaries.
|
|
145
|
+
const candidate = __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").get(slot);
|
|
146
|
+
const previous = candidate?.task === task ? candidate : undefined;
|
|
147
|
+
// llama-server may leave the previous request's decoded counter visible
|
|
148
|
+
// during the first snapshot of a newly assigned task. The task boundary
|
|
149
|
+
// is authoritative in that case; otherwise a prompt is shown as decode
|
|
150
|
+
// and prompt throughput is incorrectly reported as zero.
|
|
151
|
+
const phase = candidate !== undefined && candidate.task !== task
|
|
152
|
+
? "prefill"
|
|
153
|
+
: previous?.phase === "prefill" && generatedTokens <= previous.generatedTokens
|
|
154
|
+
? "prefill"
|
|
155
|
+
: generatedTokens === 0
|
|
156
|
+
? "prefill"
|
|
157
|
+
: "decode";
|
|
123
158
|
const elapsedSeconds = previous ? (now - previous.at) / 1000 : 0;
|
|
124
|
-
const rate = (current, before) => elapsedSeconds > 0 && before
|
|
159
|
+
const rate = (current, before) => current !== null && elapsedSeconds > 0 && before != null && current >= before
|
|
125
160
|
? (current - before) / elapsedSeconds
|
|
126
161
|
: null;
|
|
127
|
-
__classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").set(slot, { at: now, promptTokens, generatedTokens });
|
|
162
|
+
__classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").set(slot, { at: now, task, promptTokens, generatedTokens, phase });
|
|
128
163
|
return [
|
|
129
164
|
{
|
|
130
165
|
slot,
|
|
@@ -140,13 +175,13 @@ export class SlotActivityTracker {
|
|
|
140
175
|
}
|
|
141
176
|
}
|
|
142
177
|
_SlotActivityTracker_previous = new WeakMap();
|
|
143
|
-
function
|
|
178
|
+
function optionalCounter(record, keys) {
|
|
144
179
|
for (const key of keys) {
|
|
145
180
|
const value = record[key];
|
|
146
181
|
if (typeof value === "number" && Number.isFinite(value))
|
|
147
182
|
return value;
|
|
148
183
|
}
|
|
149
|
-
return
|
|
184
|
+
return null;
|
|
150
185
|
}
|
|
151
186
|
const slotActivityTracker = new SlotActivityTracker();
|
|
152
187
|
/** Slot occupancy from the running server. */
|
package/dist/types.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface Model {
|
|
|
49
49
|
tier?: string;
|
|
50
50
|
/** Whether the catalog marks this as a thinking/reasoning model. */
|
|
51
51
|
thinking?: boolean;
|
|
52
|
+
/** Per-request reasoning levels accepted by the model's OpenAI-compatible API. */
|
|
53
|
+
reasoningEfforts?: string[];
|
|
52
54
|
/** The catalog's advertised max context, if known. */
|
|
53
55
|
contextMax?: number;
|
|
54
56
|
/** Back-reference: the id of the reconciled catalog entry, if matched. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@otto-code/brain",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"bin": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^20.9.0",
|
|
52
52
|
"typescript": "^5.9.3",
|
|
53
|
-
"vitest": "^4.1.
|
|
53
|
+
"vitest": "^4.1.10"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
56
56
|
"node": ">=20.6.0"
|