@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,74 @@
1
+ /**
2
+ * Cooperative single-GPU scheduler with per-model concurrency.
3
+ *
4
+ * Only one model is resident at a time. Completion requests are queued rather
5
+ * than refused for "wrong / no model loaded".
6
+ *
7
+ * - Requests for the resident model run concurrently, up to that model's
8
+ * `parallelSlots` (the same number of sequence slots llama-server was
9
+ * launched with — sending more would only queue inside llama-server). Extra
10
+ * same-model requests wait for a free slot; they never trigger a load.
11
+ * - Requests for a *different* model wait for a model switch. When the resident
12
+ * model's current batch drains, the scheduler switches and serves the other
13
+ * model's batch — so two clients wanting different models share the GPU by
14
+ * taking turns.
15
+ *
16
+ * Fairness: after a model finishes a turn, the next turn prefers a *different*
17
+ * model when one is waiting, so a steady stream for model A cannot starve model
18
+ * B. A turn is a snapshot: requests that arrive for a model mid-turn wait for
19
+ * its next turn.
20
+ *
21
+ * The scheduler is transport-agnostic: a job is a resolved catalog model plus a
22
+ * `run()` that does the proxying, which keeps the turn logic unit-testable.
23
+ */
24
+ import type { Model } from "../types.js";
25
+ import type { Profile } from "../config/schema.js";
26
+ /** The subset of the supervisor the scheduler observes to size and route turns. */
27
+ export interface SchedulerSupervisor {
28
+ state: string;
29
+ model: Model | null;
30
+ profile: Profile | null;
31
+ }
32
+ export interface SchedulerOptions {
33
+ supervisor: SchedulerSupervisor;
34
+ loadModel: (model: Model) => Promise<void>;
35
+ logger?: ((message: string) => void) | null;
36
+ }
37
+ /** A queued completion request bound to a resolved catalog model. */
38
+ export interface QueuedJob {
39
+ modelId: string;
40
+ model: Model;
41
+ run: () => Promise<unknown>;
42
+ resolve: (value: unknown) => void;
43
+ reject: (error: unknown) => void;
44
+ }
45
+ export interface SchedulerStats {
46
+ queued: number;
47
+ waiting: Record<string, number>;
48
+ lastTurn: string | null;
49
+ }
50
+ export declare class Scheduler {
51
+ #private;
52
+ supervisor: SchedulerSupervisor;
53
+ loadModel: (model: Model) => Promise<void>;
54
+ logger: ((message: string) => void) | null;
55
+ queue: QueuedJob[];
56
+ lastTurnId: string | null;
57
+ pumping: boolean;
58
+ constructor({ supervisor, loadModel, logger }: SchedulerOptions);
59
+ /** Id of the model that is actually loaded and ready, or null. */
60
+ get loadedId(): string | null;
61
+ /** How many requests may run at once against the resident model. */
62
+ get concurrency(): number;
63
+ /**
64
+ * Queue a job for an already-resolved catalog model. `run` is invoked once
65
+ * that model is the resident one; the returned promise settles when run does.
66
+ * Pumping is deferred a microtask so a burst of requests submitted together
67
+ * share one turn rather than the first one snapshotting a turn by itself.
68
+ */
69
+ submit(model: Model, run: () => Promise<unknown>): Promise<unknown>;
70
+ pump(): Promise<void>;
71
+ /** Queue snapshot for the status endpoint / UI. */
72
+ stats(): SchedulerStats;
73
+ }
74
+ //# sourceMappingURL=scheduler.d.ts.map
@@ -0,0 +1,110 @@
1
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
2
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
3
+ 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");
4
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
+ };
6
+ var _Scheduler_instances, _Scheduler_take, _Scheduler_serveTurn;
7
+ const MAX_CONCURRENCY = 16;
8
+ export class Scheduler {
9
+ constructor({ supervisor, loadModel, logger = null }) {
10
+ _Scheduler_instances.add(this);
11
+ this.supervisor = supervisor;
12
+ this.loadModel = loadModel; // async (model) => resolves once it is ready
13
+ this.logger = logger; // optional (message: string) => void
14
+ this.queue = []; // { modelId, model, run, resolve, reject }
15
+ this.lastTurnId = null;
16
+ this.pumping = false;
17
+ }
18
+ /** Id of the model that is actually loaded and ready, or null. */
19
+ get loadedId() {
20
+ return this.supervisor.state === "ready" && this.supervisor.model
21
+ ? this.supervisor.model.id
22
+ : null;
23
+ }
24
+ /** How many requests may run at once against the resident model. */
25
+ get concurrency() {
26
+ return Math.max(1, Math.min(MAX_CONCURRENCY, this.supervisor.profile?.parallelSlots || 1));
27
+ }
28
+ /**
29
+ * Queue a job for an already-resolved catalog model. `run` is invoked once
30
+ * that model is the resident one; the returned promise settles when run does.
31
+ * Pumping is deferred a microtask so a burst of requests submitted together
32
+ * share one turn rather than the first one snapshotting a turn by itself.
33
+ */
34
+ submit(model, run) {
35
+ return new Promise((resolve, reject) => {
36
+ this.queue.push({ modelId: model.id, model, run, resolve, reject });
37
+ queueMicrotask(() => this.pump());
38
+ });
39
+ }
40
+ async pump() {
41
+ if (this.pumping)
42
+ return;
43
+ this.pumping = true;
44
+ try {
45
+ while (this.queue.length) {
46
+ // Prefer a model other than the one that just took a turn, so the two
47
+ // sides alternate; fall back to the head of the queue when only one
48
+ // model is waiting (it simply keeps its turn).
49
+ const pick = this.queue.find((j) => j.modelId !== this.lastTurnId) || this.queue[0];
50
+ const turnId = pick.modelId;
51
+ if (this.loadedId !== turnId) {
52
+ try {
53
+ this.logger?.(`switching to ${pick.model.displayName}`);
54
+ await this.loadModel(pick.model);
55
+ }
56
+ catch (error) {
57
+ // The model would not load — fail exactly its queued jobs and move on.
58
+ for (const job of __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, (j) => j.modelId === turnId))
59
+ job.reject(error);
60
+ continue;
61
+ }
62
+ }
63
+ this.lastTurnId = turnId;
64
+ await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_serveTurn).call(this, turnId);
65
+ }
66
+ }
67
+ finally {
68
+ this.pumping = false;
69
+ }
70
+ }
71
+ /** Queue snapshot for the status endpoint / UI. */
72
+ stats() {
73
+ const waiting = {};
74
+ for (const job of this.queue) {
75
+ const name = job.model.displayName;
76
+ waiting[name] = (waiting[name] || 0) + 1;
77
+ }
78
+ return { queued: this.queue.length, waiting, lastTurn: this.lastTurnId };
79
+ }
80
+ }
81
+ _Scheduler_instances = new WeakSet(), _Scheduler_take = function _Scheduler_take(pred) {
82
+ const kept = [];
83
+ const taken = [];
84
+ for (const job of this.queue)
85
+ (pred(job) ? taken : kept).push(job);
86
+ this.queue = kept;
87
+ return taken;
88
+ }, _Scheduler_serveTurn =
89
+ /** Serve one model's snapshot with bounded concurrency. */
90
+ async function _Scheduler_serveTurn(turnId) {
91
+ const jobs = __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, (j) => j.modelId === turnId);
92
+ let next = 0;
93
+ const worker = async () => {
94
+ while (next < jobs.length) {
95
+ const job = jobs[next];
96
+ next += 1;
97
+ try {
98
+ job.resolve(await job.run());
99
+ }
100
+ catch (error) {
101
+ job.reject(error);
102
+ }
103
+ }
104
+ };
105
+ const pool = [];
106
+ for (let i = 0; i < Math.min(this.concurrency, jobs.length); i += 1)
107
+ pool.push(worker());
108
+ await Promise.all(pool);
109
+ };
110
+ //# sourceMappingURL=scheduler.js.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The headless brain service: the router + supervisor bound to a port, with the
3
+ * VRAM fit, on-demand model switching, remote auth, built-in TLS, and pid-file
4
+ * lifecycle. Used both by `otto brain serve` (foreground) and by a detached
5
+ * `otto brain start`. It stays provider-neutral about the runtime source — it
6
+ * takes whatever resolveRuntime picks (managed or LM Studio).
7
+ *
8
+ * TLS is served in-process (config.tls): HTTPS with a files / self-signed /
9
+ * tailscale certificate, hot-swapped on renewal. This is what lets the brain be
10
+ * exposed securely over a network with no relay in front of it.
11
+ */
12
+ import http from "node:http";
13
+ import type { BrainConfig } from "../config/schema.js";
14
+ import type { Model } from "../types.js";
15
+ import { Supervisor } from "./supervisor.js";
16
+ /**
17
+ * Pull the client's presented key from the request. Accepts, in order, an
18
+ * `Authorization: Bearer …`, an `x-api-key` (OpenAI/Anthropic convention, and
19
+ * what the relay accepted), or the brain's own `x-otto-brain-token`.
20
+ */
21
+ export declare function extractToken(req: http.IncomingMessage): string | null;
22
+ export interface StartServiceOptions {
23
+ config: BrainConfig;
24
+ modelNeedle?: string;
25
+ env?: NodeJS.ProcessEnv;
26
+ onLog?: (line: string) => void;
27
+ }
28
+ export interface ServiceHandle {
29
+ server: http.Server;
30
+ supervisor: Supervisor;
31
+ host: string;
32
+ port: number;
33
+ model: Model;
34
+ /** Whether the listener terminates TLS (config.tls.mode !== "off"). */
35
+ secure: boolean;
36
+ /** The address to show a user: the MagicDNS/cert hostname when TLS is on, else the bind host. */
37
+ displayHost: string;
38
+ stop: () => Promise<void>;
39
+ }
40
+ export declare function startService({ config, modelNeedle, env, onLog, }: StartServiceOptions): Promise<ServiceHandle>;
41
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The headless brain service: the router + supervisor bound to a port, with the
3
+ * VRAM fit, on-demand model switching, remote auth, built-in TLS, and pid-file
4
+ * lifecycle. Used both by `otto brain serve` (foreground) and by a detached
5
+ * `otto brain start`. It stays provider-neutral about the runtime source — it
6
+ * takes whatever resolveRuntime picks (managed or LM Studio).
7
+ *
8
+ * TLS is served in-process (config.tls): HTTPS with a files / self-signed /
9
+ * tailscale certificate, hot-swapped on renewal. This is what lets the brain be
10
+ * exposed securely over a network with no relay in front of it.
11
+ */
12
+ import http from "node:http";
13
+ import https from "node:https";
14
+ import { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
15
+ import { resolveBrainPaths } from "../config/paths.js";
16
+ import { query as queryGpu } from "../gpu.js";
17
+ import { pickModel, scanModels } from "../models/index.js";
18
+ import { CommandError } from "../output/types.js";
19
+ import { resolveRuntime } from "../runtime/index.js";
20
+ import * as vram from "../vram.js";
21
+ import { resolveVersion } from "../version.js";
22
+ import * as results from "../ops/results.js";
23
+ import { createRouter, Telemetry } from "./router.js";
24
+ import { Supervisor } from "./supervisor.js";
25
+ import * as tailscale from "./tailscale.js";
26
+ import { CertManager, resolveTlsOptions } from "./tls.js";
27
+ import { removePidFile, writePidFile } from "./pid-lock.js";
28
+ /** The effective config with secrets masked, for the `/__host/config` read. */
29
+ function redactConfig(config) {
30
+ return {
31
+ ...config,
32
+ auth: { ...config.auth, token: config.auth.token ? "********" : null },
33
+ // The Hugging Face token is the owner's account credential — never echo it to
34
+ // a caller of /__host/config (read is allowed by default on a shared brain).
35
+ ...(config.hfToken ? { hfToken: "********" } : {}),
36
+ };
37
+ }
38
+ /** Benchmark rankings + per-config variance + best-per-config, for `/__host/evals`. */
39
+ function collectEvals() {
40
+ try {
41
+ const all = results.loadAll();
42
+ return {
43
+ rankings: results.rankModels(all),
44
+ latest: results.latestPerConfig(all),
45
+ variance: results.variance(all),
46
+ runCount: all.length,
47
+ };
48
+ }
49
+ catch {
50
+ return { rankings: [], latest: [], variance: [], runCount: 0 };
51
+ }
52
+ }
53
+ function isLoopback(host) {
54
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
55
+ }
56
+ /**
57
+ * Pull the client's presented key from the request. Accepts, in order, an
58
+ * `Authorization: Bearer …`, an `x-api-key` (OpenAI/Anthropic convention, and
59
+ * what the relay accepted), or the brain's own `x-otto-brain-token`.
60
+ */
61
+ export function extractToken(req) {
62
+ const auth = req.headers.authorization;
63
+ if (typeof auth === "string" && auth.startsWith("Bearer "))
64
+ return auth.slice(7).trim();
65
+ const apiKey = req.headers["x-api-key"];
66
+ if (typeof apiKey === "string" && apiKey)
67
+ return apiKey;
68
+ const header = req.headers["x-otto-brain-token"];
69
+ return typeof header === "string" ? header : null;
70
+ }
71
+ /** Gate the router with a bearer token when configured; /health stays open. */
72
+ function withAuth(inner, config) {
73
+ const token = config.auth.mode === "token" ? config.auth.token : null;
74
+ if (!token)
75
+ return inner;
76
+ return (req, res) => {
77
+ if (req.url !== "/health" && extractToken(req) !== token) {
78
+ res.writeHead(401, { "content-type": "application/json" });
79
+ res.end(JSON.stringify({ error: "unauthorized" }));
80
+ return;
81
+ }
82
+ inner(req, res);
83
+ };
84
+ }
85
+ export async function startService({ config, modelNeedle, env = process.env, onLog = () => { }, }) {
86
+ const runtime = resolveRuntime(config, env);
87
+ if (!runtime) {
88
+ throw new CommandError({
89
+ code: "NO_RUNTIME",
90
+ message: "no llama.cpp runtime available",
91
+ details: "run `otto brain runtime install` to download one, or install LM Studio",
92
+ });
93
+ }
94
+ const paths = resolveBrainPaths(env);
95
+ const tlsOptions = await resolveTlsOptions(config, paths);
96
+ // `listen.host: "tailscale"` binds the tailnet interface only (invisible to the
97
+ // LAN and the internet), mirroring the relay's default. Any other value binds
98
+ // verbatim. The cert hostname is what a client actually connects to.
99
+ const port = config.listen.port;
100
+ const bindHost = config.listen.host === "tailscale"
101
+ ? await tailscale.ipv4(config.tls.tailscaleExe ?? undefined)
102
+ : config.listen.host;
103
+ const displayHost = tlsOptions?.hostname ?? bindHost;
104
+ // Auth is orthogonal to transport: TLS encrypts the pipe, a token authorizes the
105
+ // caller. A non-loopback bind still needs a token even over HTTPS.
106
+ if (!isLoopback(bindHost) &&
107
+ config.auth.mode !== "token" &&
108
+ !config.allowInsecureBind &&
109
+ env.OTTO_BRAIN_ALLOW_INSECURE !== "1") {
110
+ throw new CommandError({
111
+ code: "INSECURE_BIND",
112
+ message: `refusing to bind ${bindHost} without auth`,
113
+ details: "set auth.mode=token, or allowInsecureBind=true for an open trusted-network share " +
114
+ "(or OTTO_BRAIN_ALLOW_INSECURE=1 to override)",
115
+ });
116
+ }
117
+ const store = loadProfilesStore();
118
+ const catalog = scanModels(config, env);
119
+ const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
120
+ const model = pickModel(catalog, needle);
121
+ let profile = forModel(store, model, config.defaults);
122
+ const gpu = await queryGpu();
123
+ if (gpu) {
124
+ const fit = vram.fitToBudget({
125
+ model,
126
+ profile,
127
+ calibration: getCalibration(store, model, profile),
128
+ totalVramBytes: gpu.totalBytes,
129
+ });
130
+ if (!fit.adjusted && !fit.budget.fits) {
131
+ throw new CommandError({
132
+ code: "DOES_NOT_FIT",
133
+ message: `refusing to start: ${fit.reason}`,
134
+ details: "use a smaller quant, or run `otto brain calibrate` for a measured budget",
135
+ });
136
+ }
137
+ if (fit.adjusted && fit.reason)
138
+ onLog(`note: ${fit.reason}`);
139
+ profile = fit.profile;
140
+ }
141
+ const telemetry = new Telemetry();
142
+ const supervisor = new Supervisor({ runtime });
143
+ supervisor.on("log", (line) => {
144
+ if (/error|failed|warn/i.test(line))
145
+ onLog(line);
146
+ });
147
+ // Serialize model switches: the router queues request-driven switches, but the
148
+ // config path (POST /__host/config) calls loadModel directly. Chaining here
149
+ // guarantees two switches (e.g. a config write racing a request-driven switch)
150
+ // can never overlap two supervisor.start() calls, whichever caller triggers them.
151
+ let modelSwitchChain = Promise.resolve();
152
+ const loadModelUnsafe = async (target) => {
153
+ const gpuInfo = await queryGpu();
154
+ let fitProfile = forModel(store, target, config.defaults);
155
+ if (gpuInfo) {
156
+ const fit = vram.fitToBudget({
157
+ model: target,
158
+ profile: fitProfile,
159
+ calibration: getCalibration(store, target, fitProfile),
160
+ totalVramBytes: gpuInfo.totalBytes,
161
+ });
162
+ if (!fit.adjusted && !fit.budget.fits)
163
+ throw new Error(fit.reason ?? "does not fit");
164
+ fitProfile = fit.profile;
165
+ }
166
+ await supervisor.start(target, fitProfile);
167
+ store.lastModelId = target.id;
168
+ saveProfilesStore(store);
169
+ };
170
+ const loadModel = (target) => {
171
+ const run = modelSwitchChain.then(() => loadModelUnsafe(target));
172
+ // Keep the chain alive even if this switch fails, so a later switch still runs.
173
+ modelSwitchChain = run.catch(() => undefined);
174
+ return run;
175
+ };
176
+ // Apply an editable config patch from POST /__host/config: mutate the live
177
+ // config (so the lock/default getters and future starts see it), persist it to
178
+ // config.json without baking in env overrides, and hot-switch the model when a
179
+ // new default is named. Network/TLS/auth are host-owned and not accepted here.
180
+ const applyConfigPatch = async (patch) => {
181
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
182
+ throw new Error("config patch must be an object");
183
+ }
184
+ const p = patch;
185
+ let switchTo = null;
186
+ if ("defaultModel" in p) {
187
+ const next = p.defaultModel;
188
+ if (next !== null && typeof next !== "string") {
189
+ throw new Error("defaultModel must be a string or null");
190
+ }
191
+ if ((next ?? null) !== config.defaultModel)
192
+ switchTo = next ?? null;
193
+ config.defaultModel = next ?? null;
194
+ }
195
+ if ("lockModel" in p) {
196
+ if (typeof p.lockModel !== "boolean")
197
+ throw new Error("lockModel must be a boolean");
198
+ config.lockModel = p.lockModel;
199
+ }
200
+ const persisted = loadPersistedConfig(paths);
201
+ persisted.defaultModel = config.defaultModel;
202
+ persisted.lockModel = config.lockModel;
203
+ saveBrainConfig(persisted, paths);
204
+ if (switchTo) {
205
+ const target = catalog.find((m) => m.displayName === switchTo || m.id === switchTo);
206
+ if (target)
207
+ await loadModel(target);
208
+ }
209
+ return redactConfig(config);
210
+ };
211
+ const handler = withAuth(createRouter({
212
+ supervisor,
213
+ telemetry,
214
+ logger: { warn: (m) => onLog(`WARN ${m}`) },
215
+ getCatalog: () => catalog,
216
+ loadModel,
217
+ version: resolveVersion(),
218
+ getConfig: () => redactConfig(config),
219
+ getEvals: collectEvals,
220
+ getLockModel: () => config.lockModel,
221
+ getDefaultModel: () => config.defaultModel,
222
+ applyConfigPatch,
223
+ getAllowConfigWrite: () => config.allowRemoteConfig,
224
+ }), config);
225
+ // TLS terminates in-process when configured; otherwise plain HTTP. The cert
226
+ // manager issues/generates the first keypair before we listen, and hot-swaps
227
+ // the secure context on renewal without dropping connections.
228
+ let certManager = null;
229
+ let server;
230
+ if (tlsOptions) {
231
+ certManager = new CertManager({ ...tlsOptions, logger: { info: onLog, warn: onLog } });
232
+ const secure = await certManager.load();
233
+ const httpsServer = https.createServer({ key: secure.key, cert: secure.cert }, handler);
234
+ certManager.on("renewed", (pair) => {
235
+ httpsServer.setSecureContext({ key: pair.key, cert: pair.cert });
236
+ onLog("note: TLS certificate hot-swapped");
237
+ });
238
+ server = httpsServer;
239
+ }
240
+ else {
241
+ server = http.createServer(handler);
242
+ }
243
+ server.keepAliveTimeout = 75000;
244
+ server.requestTimeout = 0;
245
+ await new Promise((resolve, reject) => {
246
+ server.once("error", reject);
247
+ server.listen(port, bindHost, resolve);
248
+ });
249
+ certManager?.start();
250
+ await supervisor.start(model, profile);
251
+ store.lastModelId = model.id;
252
+ saveProfilesStore(store);
253
+ writePidFile({
254
+ pid: process.pid,
255
+ host: bindHost,
256
+ port,
257
+ startedAt: new Date().toISOString(),
258
+ secure: Boolean(tlsOptions),
259
+ displayHost,
260
+ }, env);
261
+ const stop = async () => {
262
+ certManager?.stop();
263
+ await supervisor.stop();
264
+ await new Promise((resolve) => server.close(() => resolve()));
265
+ removePidFile(env);
266
+ };
267
+ return {
268
+ server,
269
+ supervisor,
270
+ host: bindHost,
271
+ port,
272
+ model,
273
+ secure: Boolean(tlsOptions),
274
+ displayHost,
275
+ stop,
276
+ };
277
+ }
278
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1,67 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import type { Model, Runtime } from "../types.js";
4
+ import type { Profile } from "../config/schema.js";
5
+ /**
6
+ * Default loopback port for the private llama-server child. Deliberately clear
7
+ * of Otto's space: 8081 (the old default) is the Expo/Metro dev port, so a brain
8
+ * started from the dev checkout collided with the running app - the server bound
9
+ * a port the app also wanted, and benchmark requests hit Metro (or a dead
10
+ * socket) instead of the model. This range sits above Otto's daemon ports
11
+ * (6788/6868) and app port (8081/19000) and below the Windows ephemeral range.
12
+ * Calibrate and sweep run their own supervisors at +1/+2 so they never collide
13
+ * with a main service already holding the base port.
14
+ */
15
+ export declare const DEFAULT_INTERNAL_PORT = 20800;
16
+ export type SupervisorState = "stopped" | "starting" | "ready" | "failed" | "stopping";
17
+ export interface SupervisorOptions {
18
+ runtime: Runtime;
19
+ internalPort?: number;
20
+ host?: string;
21
+ readyTimeoutMs?: number;
22
+ }
23
+ export interface SupervisorStatus {
24
+ state: SupervisorState;
25
+ model: string | null;
26
+ modelId: string | null;
27
+ pid: number | null;
28
+ loadSeconds: number | null;
29
+ vramBytes: number | null;
30
+ startedAt: string | null;
31
+ lastError: string | null;
32
+ upstream: string;
33
+ runtime: string;
34
+ }
35
+ /**
36
+ * Owns the llama-server child process.
37
+ *
38
+ * The server always listens on a private port; `router.js` fronts it on a
39
+ * stable one so switching models never asks a client to reconnect elsewhere.
40
+ */
41
+ export declare class Supervisor extends EventEmitter {
42
+ #private;
43
+ runtime: Runtime;
44
+ internalPort: number;
45
+ host: string;
46
+ readyTimeoutMs: number;
47
+ state: SupervisorState;
48
+ child: ChildProcess | null;
49
+ model: Model | null;
50
+ profile: Profile | null;
51
+ logLines: string[];
52
+ lastError: string | null;
53
+ startedAt: Date | null;
54
+ loadSeconds: number | null;
55
+ vramAtReadyBytes: number | null;
56
+ vramBaselineBytes: number | null;
57
+ command: string | null;
58
+ constructor({ runtime, internalPort, host, readyTimeoutMs, }: SupervisorOptions);
59
+ get upstreamBase(): string;
60
+ /** Start (or restart) the server for a model + profile. */
61
+ start(model: Model, profile: Profile): Promise<this>;
62
+ /** Fetch /props from the running server (modalities, template caps, defaults). */
63
+ props(): Promise<unknown>;
64
+ stop(): Promise<void>;
65
+ status(): SupervisorStatus;
66
+ }
67
+ //# sourceMappingURL=supervisor.d.ts.map