@otto-code/brain 0.8.19 → 0.9.2
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/models/scan.js +92 -8
- package/dist/runtime/managed.js +5 -1
- package/dist/service/host-api.js +11 -6
- package/dist/service/log-format.js +21 -7
- package/package.json +1 -1
package/dist/models/scan.js
CHANGED
|
@@ -9,12 +9,95 @@ import os from "node:os";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import * as gguf from "../gguf.js";
|
|
11
11
|
export const LMSTUDIO_MODELS_DIR = path.join(os.homedir(), ".lmstudio", "models");
|
|
12
|
-
// The quant is the terminal GGUF filename suffix, optionally preceded by a
|
|
13
|
-
// source-defined qualifier such as Muse's `UD-`. Matching a known substring
|
|
14
|
-
// truncates newer labels (for example Q2_K_XL -> Q2_K) and misses them when
|
|
15
|
-
// their exact spelling is not in a hard-coded list.
|
|
16
|
-
const QUANT_SUFFIX = /(?:^|[-_])((?:UD-)?(?:IQ[1-4]|Q[2-8])(?:_[A-Z0-9]+)*|NVFP\d+|MXFP\d+|BF16|F16|F32)(?:-(?:MTP|IMATRIX|DISTILL))?(?:-\d{5}-OF-\d{5})?\.GGUF$/i;
|
|
17
12
|
const MULTIPART = /-(\d{5})-of-(\d{5})\.gguf$/i;
|
|
13
|
+
const QUANT_TRAILING_MARKERS = ["-MTP", "-IMATRIX", "-DISTILL"];
|
|
14
|
+
function isDigit(char) {
|
|
15
|
+
return char >= "0" && char <= "9";
|
|
16
|
+
}
|
|
17
|
+
function isUppercaseLetterOrDigit(char) {
|
|
18
|
+
return (char >= "A" && char <= "Z") || isDigit(char);
|
|
19
|
+
}
|
|
20
|
+
function isQuant(value) {
|
|
21
|
+
if (["BF16", "F16", "F32"].includes(value))
|
|
22
|
+
return true;
|
|
23
|
+
if (value.startsWith("IQ")) {
|
|
24
|
+
return value.length === 3 && value[2] >= "1" && value[2] <= "4";
|
|
25
|
+
}
|
|
26
|
+
if (value.startsWith("NVFP") || value.startsWith("MXFP")) {
|
|
27
|
+
const digits = value.slice(4);
|
|
28
|
+
return digits.length > 0 && [...digits].every(isDigit);
|
|
29
|
+
}
|
|
30
|
+
if (!value.startsWith("Q") || value.length < 2 || value[1] < "2" || value[1] > "8") {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
let separatorExpected = false;
|
|
34
|
+
for (const char of value.slice(2)) {
|
|
35
|
+
if (char === "_") {
|
|
36
|
+
if (separatorExpected)
|
|
37
|
+
return false;
|
|
38
|
+
separatorExpected = true;
|
|
39
|
+
}
|
|
40
|
+
else if (isUppercaseLetterOrDigit(char)) {
|
|
41
|
+
separatorExpected = false;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return !separatorExpected;
|
|
48
|
+
}
|
|
49
|
+
function startsQuant(value, index) {
|
|
50
|
+
const first = value[index]?.toUpperCase();
|
|
51
|
+
const second = value[index + 1]?.toUpperCase();
|
|
52
|
+
if (first === "Q")
|
|
53
|
+
return second >= "2" && second <= "8";
|
|
54
|
+
if (first === "I")
|
|
55
|
+
return second === "Q" && value[index + 2] >= "1" && value[index + 2] <= "4";
|
|
56
|
+
if (first === "N")
|
|
57
|
+
return value.slice(index, index + 4).toUpperCase() === "NVFP";
|
|
58
|
+
if (first === "M")
|
|
59
|
+
return value.slice(index, index + 4).toUpperCase() === "MXFP";
|
|
60
|
+
if (first === "B")
|
|
61
|
+
return value.slice(index, index + 4).toUpperCase() === "BF16";
|
|
62
|
+
if (first === "F") {
|
|
63
|
+
const prefix = value.slice(index, index + 3).toUpperCase();
|
|
64
|
+
return prefix === "F16" || prefix === "F32";
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
function stripQuantSuffix(filename) {
|
|
69
|
+
let stem = path.basename(filename);
|
|
70
|
+
if (!stem.toLowerCase().endsWith(".gguf"))
|
|
71
|
+
return null;
|
|
72
|
+
stem = stem.slice(0, -".gguf".length);
|
|
73
|
+
const multipart = stem.slice(-15);
|
|
74
|
+
if (multipart.length === 15 &&
|
|
75
|
+
multipart[0] === "-" &&
|
|
76
|
+
multipart[6] === "-" &&
|
|
77
|
+
multipart.slice(7, 9).toLowerCase() === "of" &&
|
|
78
|
+
multipart[9] === "-" &&
|
|
79
|
+
[...multipart.slice(1, 6), ...multipart.slice(10)].every(isDigit)) {
|
|
80
|
+
stem = stem.slice(0, -multipart.length);
|
|
81
|
+
}
|
|
82
|
+
const upperStem = stem.toUpperCase();
|
|
83
|
+
for (const marker of QUANT_TRAILING_MARKERS) {
|
|
84
|
+
if (upperStem.endsWith(marker)) {
|
|
85
|
+
stem = stem.slice(0, -marker.length);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let candidateStart = -1;
|
|
90
|
+
for (let index = 0; index < stem.length; index += 1) {
|
|
91
|
+
if (index > 0 && stem[index - 1] !== "-" && stem[index - 1] !== "_")
|
|
92
|
+
continue;
|
|
93
|
+
if (startsQuant(stem, index))
|
|
94
|
+
candidateStart = index;
|
|
95
|
+
}
|
|
96
|
+
if (candidateStart === -1)
|
|
97
|
+
return null;
|
|
98
|
+
const candidate = stem.slice(candidateStart).toUpperCase();
|
|
99
|
+
return isQuant(candidate) ? candidate : null;
|
|
100
|
+
}
|
|
18
101
|
function walk(dir, out = []) {
|
|
19
102
|
let entries;
|
|
20
103
|
try {
|
|
@@ -33,9 +116,10 @@ function walk(dir, out = []) {
|
|
|
33
116
|
return out;
|
|
34
117
|
}
|
|
35
118
|
export function detectQuant(filename) {
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
119
|
+
// A source qualifier such as `UD-` is not part of the user-facing quant.
|
|
120
|
+
// Scan terminal candidates rather than matching a nested filename regex:
|
|
121
|
+
// model filenames are host input and this remains linear for malformed names.
|
|
122
|
+
return stripQuantSuffix(filename);
|
|
39
123
|
}
|
|
40
124
|
export function isProjectorFile(filename) {
|
|
41
125
|
return /^mmproj/i.test(path.basename(filename));
|
package/dist/runtime/managed.js
CHANGED
|
@@ -249,7 +249,11 @@ function slug(spec) {
|
|
|
249
249
|
.replace(/(^-|-$)/g, "");
|
|
250
250
|
}
|
|
251
251
|
function displayNameForManagedRuntime(label, version) {
|
|
252
|
-
|
|
252
|
+
const suffix = "(managed)";
|
|
253
|
+
const withoutSuffix = label.toLowerCase().endsWith(suffix)
|
|
254
|
+
? label.slice(0, -suffix.length).trimEnd()
|
|
255
|
+
: label;
|
|
256
|
+
return `${withoutSuffix} · ${version} (Otto managed)`;
|
|
253
257
|
}
|
|
254
258
|
function legacyManagedDisplayName(dirName, version) {
|
|
255
259
|
const cuda = /^cuda-(\d+)-(\d+)-managed(?:-|$)/iu.exec(dirName);
|
package/dist/service/host-api.js
CHANGED
|
@@ -355,6 +355,11 @@ export function createHostApi(deps) {
|
|
|
355
355
|
sendError(res, 403, "remote configuration is disabled on this brain; enable it with `otto brain share --allow-config`");
|
|
356
356
|
return false;
|
|
357
357
|
};
|
|
358
|
+
/** Keep implementation details in the local service log, never the HTTP response. */
|
|
359
|
+
const sendOperationError = (res, status, operation, error) => {
|
|
360
|
+
deps.log?.("server", `${operation}: ${errorMessage(error)}`);
|
|
361
|
+
sendError(res, status, operation);
|
|
362
|
+
};
|
|
358
363
|
const inventory = async () => {
|
|
359
364
|
const [gpu, store] = [await deps.queryGpuInfo(), deps.getProfilesStore()];
|
|
360
365
|
const defaults = deps.getProfileDefaults();
|
|
@@ -445,7 +450,7 @@ export function createHostApi(deps) {
|
|
|
445
450
|
});
|
|
446
451
|
}
|
|
447
452
|
catch (error) {
|
|
448
|
-
|
|
453
|
+
sendOperationError(res, 400, "could not update the model profile", error);
|
|
449
454
|
}
|
|
450
455
|
})();
|
|
451
456
|
});
|
|
@@ -537,7 +542,7 @@ export function createHostApi(deps) {
|
|
|
537
542
|
});
|
|
538
543
|
}
|
|
539
544
|
catch (error) {
|
|
540
|
-
|
|
545
|
+
sendOperationError(res, 400, "could not calculate the model budget", error);
|
|
541
546
|
}
|
|
542
547
|
})();
|
|
543
548
|
};
|
|
@@ -630,7 +635,7 @@ export function createHostApi(deps) {
|
|
|
630
635
|
});
|
|
631
636
|
}
|
|
632
637
|
catch (error) {
|
|
633
|
-
|
|
638
|
+
sendOperationError(res, 409, "could not remove the model component", error);
|
|
634
639
|
}
|
|
635
640
|
};
|
|
636
641
|
const handleLogs = (res, params) => {
|
|
@@ -778,7 +783,7 @@ export function createHostApi(deps) {
|
|
|
778
783
|
});
|
|
779
784
|
}
|
|
780
785
|
catch (error) {
|
|
781
|
-
|
|
786
|
+
sendOperationError(res, 409, "could not start the benchmark", error);
|
|
782
787
|
}
|
|
783
788
|
});
|
|
784
789
|
return true;
|
|
@@ -916,7 +921,7 @@ export function createHostApi(deps) {
|
|
|
916
921
|
});
|
|
917
922
|
}
|
|
918
923
|
catch (error) {
|
|
919
|
-
|
|
924
|
+
sendOperationError(res, 400, "could not start the job", error);
|
|
920
925
|
}
|
|
921
926
|
});
|
|
922
927
|
return true;
|
|
@@ -1091,7 +1096,7 @@ export function createHostApi(deps) {
|
|
|
1091
1096
|
}));
|
|
1092
1097
|
}
|
|
1093
1098
|
catch (error) {
|
|
1094
|
-
|
|
1099
|
+
sendOperationError(res, 500, "could not build the model details", error);
|
|
1095
1100
|
}
|
|
1096
1101
|
})();
|
|
1097
1102
|
return true;
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
const TAGGED_LINE = /^\[(?:brain|llama-server)\]/u;
|
|
2
|
-
const SOURCE_AND_AREA = /^(\[(?:brain|llama-server)\])(?:\s+(\[(?:library|model|api|server)\]))?\s*(.*)$/u;
|
|
3
1
|
const LLAMA_SERVER_PREFIX = /^\d+(?:\.\d+){3}\s+[A-Z]\s+\S+\s+(?:\S+:\s+)?(.+)$/u;
|
|
2
|
+
const SOURCES = ["brain", "llama-server"];
|
|
3
|
+
const AREAS = ["library", "model", "api", "server"];
|
|
4
|
+
function taggedValue(line, values) {
|
|
5
|
+
for (const value of values) {
|
|
6
|
+
const tag = `[${value}]`;
|
|
7
|
+
if (line.startsWith(tag))
|
|
8
|
+
return tag;
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
4
12
|
/**
|
|
5
13
|
* Every service-owned event carries both its process source and operation area.
|
|
6
14
|
* llama-server output is separately marked by `formatLlamaServerLog`.
|
|
7
15
|
*/
|
|
8
16
|
export function formatBrainLog(area, message) {
|
|
9
|
-
return
|
|
17
|
+
return taggedValue(message, SOURCES) ? message : `[brain] [${area}] ${message}`;
|
|
10
18
|
}
|
|
11
19
|
/**
|
|
12
20
|
* Remove llama.cpp's elapsed-time, level and component columns. Otto owns the
|
|
@@ -18,15 +26,21 @@ export function stripLlamaServerPrefix(message) {
|
|
|
18
26
|
}
|
|
19
27
|
/** Preserve the useful llama.cpp message while making its process boundary explicit. */
|
|
20
28
|
export function formatLlamaServerLog(message) {
|
|
21
|
-
return
|
|
29
|
+
return taggedValue(message, SOURCES)
|
|
30
|
+
? message
|
|
31
|
+
: `[llama-server] ${stripLlamaServerPrefix(message)}`;
|
|
22
32
|
}
|
|
23
33
|
/** Place source tags ahead of the timestamp so they are scannable in a dense log. */
|
|
24
34
|
export function timestampBrainLogLine(timestamp, line) {
|
|
25
35
|
const tagged = formatBrainLog("server", line);
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
36
|
+
const source = taggedValue(tagged, SOURCES);
|
|
37
|
+
if (!source)
|
|
28
38
|
return `${timestamp} ${tagged}`;
|
|
29
|
-
|
|
39
|
+
let remainder = tagged.slice(source.length).trimStart();
|
|
40
|
+
const area = taggedValue(remainder, AREAS);
|
|
41
|
+
if (area)
|
|
42
|
+
remainder = remainder.slice(area.length).trimStart();
|
|
43
|
+
const message = remainder;
|
|
30
44
|
return `${source}${area ? ` ${area}` : ""} ${timestamp}${message ? ` ${message}` : ""}`;
|
|
31
45
|
}
|
|
32
46
|
//# sourceMappingURL=log-format.js.map
|
package/package.json
CHANGED