453 lines
19 KiB
JavaScript
453 lines
19 KiB
JavaScript
/**
|
|
* Reusable recipe data and configuration helpers. No DOM access or automatic initialization.
|
|
* Native baselines and manifest-selected JSON Schema controls come from the authored YAML catalog.
|
|
*/
|
|
import { createValidator } from "./cookbook-validator.mjs";
|
|
import { WORKLOADS } from "./cookbook-workloads.mjs";
|
|
export { WORKLOADS };
|
|
export { loadCapacityIndex, loadCapacityModel, loadCapacityHardware } from "./cookbook-capacity.mjs";
|
|
|
|
const scope = globalThis;
|
|
|
|
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
const clone = (value) =>
|
|
Array.isArray(value)
|
|
? value.map(clone)
|
|
: value !== null && typeof value === "object"
|
|
? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]))
|
|
: value;
|
|
|
|
const pathParts = (path) => path.split(".");
|
|
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
const text = (value) => typeof value === "string" && value.trim().length > 0;
|
|
|
|
/** Configuration failures retain a field path and detached Ajv-compatible details. */
|
|
export class CookbookValidationError extends Error {
|
|
constructor(message, path = null, errors = []) {
|
|
super(message);
|
|
this.name = "CookbookValidationError";
|
|
this.path = path;
|
|
this.errors = clone(errors);
|
|
}
|
|
}
|
|
|
|
function invalidValue(message, path = null, keyword = "configuration", params = {}) {
|
|
return new CookbookValidationError(message, path, [
|
|
{
|
|
instancePath: "",
|
|
schemaPath: "",
|
|
keyword,
|
|
params,
|
|
message,
|
|
},
|
|
]);
|
|
}
|
|
|
|
function requireShape(condition, context, expectation) {
|
|
if (!condition) throw new Error(`${context}: expected ${expectation}`);
|
|
}
|
|
|
|
function validateIndex(index, baseUrl) {
|
|
requireShape(object(index), "Recipe index", "an object");
|
|
requireShape(Array.isArray(index.models), "Recipe index.models", "an array");
|
|
const modelIds = new Set(),
|
|
deploymentIds = new Set();
|
|
for (const [position, model] of index.models.entries()) {
|
|
const context = `Recipe index.models[${position}]`;
|
|
requireShape(object(model), context, "a model object");
|
|
for (const key of ["id", "title", "model_id"]) requireShape(text(model[key]), `${context}.${key}`, "nonempty text");
|
|
requireShape(!modelIds.has(model.id), `${context}.id (${model.id})`, "a unique model ID");
|
|
modelIds.add(model.id);
|
|
requireShape(Array.isArray(model.deployments), `${context} (${model.id}).deployments`, "an array");
|
|
for (const [offset, deployment] of model.deployments.entries()) {
|
|
const entry = `${context} (${model.id}).deployments[${offset}]`;
|
|
requireShape(object(deployment), entry, "a deployment object");
|
|
for (const key of ["id", "label", "workload", "runtime", "catalog_url"]) {
|
|
requireShape(text(deployment[key]), `${entry}.${key}`, "nonempty text");
|
|
}
|
|
requireShape(Object.hasOwn(WORKLOADS, deployment.workload), `${entry}.workload`, "a supported workload ID");
|
|
requireShape(
|
|
!deploymentIds.has(deployment.id),
|
|
`${entry}.id (${deployment.id})`,
|
|
"a globally unique deployment ID",
|
|
);
|
|
deploymentIds.add(deployment.id);
|
|
try {
|
|
new URL(deployment.catalog_url, baseUrl);
|
|
} catch {
|
|
throw new Error(`${entry}.catalog_url: expected a URL relative to ${baseUrl}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateServer(server, context) {
|
|
requireShape(object(server), context, "an object");
|
|
requireShape(text(server.host), `${context}.host`, "nonempty text");
|
|
requireShape(Number.isInteger(server.port), `${context}.port`, "an integer");
|
|
if (own(server, "served_model_name"))
|
|
requireShape(
|
|
server.served_model_name === null || typeof server.served_model_name === "string",
|
|
`${context}.served_model_name`,
|
|
"a string or null",
|
|
);
|
|
}
|
|
|
|
function validateCatalog(catalog, deployment) {
|
|
const context = `Deployment ${deployment.id}`;
|
|
requireShape(object(catalog), context, "a catalog object");
|
|
if (catalog.id !== deployment.id) throw new Error(`Unexpected deployment: ${catalog.id}; expected ${deployment.id}`);
|
|
requireShape(object(catalog.model) && text(catalog.model.id), `${context}.model.id`, "nonempty text");
|
|
requireShape(object(catalog.base_config), `${context}.base_config`, "a configuration object");
|
|
requireShape(Array.isArray(catalog.controls), `${context}.controls`, "an array");
|
|
requireShape(
|
|
object(catalog.env) &&
|
|
Object.entries(catalog.env).every(
|
|
([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof value === "string" && !value.includes("\0"),
|
|
),
|
|
`${context}.env`,
|
|
"environment names mapped to strings",
|
|
);
|
|
const runtime = catalog.runtime;
|
|
requireShape(object(runtime), `${context}.runtime`, "an object");
|
|
requireShape(runtime.id === deployment.runtime, `${context}.runtime.id`, deployment.runtime);
|
|
requireShape(["rest", "websocket"].includes(runtime.interface), `${context}.runtime.interface`, "rest or websocket");
|
|
requireShape(
|
|
Array.isArray(runtime.launch_argv) && runtime.launch_argv.length > 0 && runtime.launch_argv.every(text),
|
|
`${context}.runtime.launch_argv`,
|
|
"a nonempty array of arguments",
|
|
);
|
|
validateServer(runtime.server_defaults, `${context}.runtime.server_defaults`);
|
|
if (own(catalog.base_config, "server"))
|
|
requireShape(object(catalog.base_config.server), `${context}.base_config.server`, "an object");
|
|
validateServer({ ...runtime.server_defaults, ...catalog.base_config.server }, `${context}.base_config.server`);
|
|
if (runtime.topology_defaults != null) {
|
|
requireShape(object(runtime.topology_defaults), `${context}.runtime.topology_defaults`, "an object");
|
|
for (const key of ["num_gpus", ...topologyDegrees]) {
|
|
requireShape(
|
|
Number.isInteger(runtime.topology_defaults[key]),
|
|
`${context}.runtime.topology_defaults.${key}`,
|
|
"an integer",
|
|
);
|
|
}
|
|
}
|
|
requireShape(catalog.workload === deployment.workload, `${context}.workload`, deployment.workload);
|
|
requireShape(Object.hasOwn(WORKLOADS, catalog.workload), `${context}.workload`, "a supported workload ID");
|
|
const paths = new Set();
|
|
for (const [position, control] of catalog.controls.entries()) {
|
|
const field = `${context}.controls[${position}]`;
|
|
requireShape(object(control) && text(control.path), `${field}.path`, "a field path");
|
|
requireShape(!paths.has(control.path), `${field}.path (${control.path})`, "a unique control path");
|
|
paths.add(control.path);
|
|
try {
|
|
checkPath(control.path, runtime);
|
|
} catch (error) {
|
|
throw new Error(`${field}.path: ${error.message}`, { cause: error });
|
|
}
|
|
requireShape(object(control.schema), `${field}.schema (${control.path})`, "a schema object");
|
|
}
|
|
if (catalog.requirements !== undefined)
|
|
requireShape(
|
|
Array.isArray(catalog.requirements) && catalog.requirements.every(text),
|
|
`${context}.requirements`,
|
|
"an array of text notes",
|
|
);
|
|
if (catalog.guide != null)
|
|
requireShape(object(catalog.guide) && text(catalog.guide.url), `${context}.guide.url`, "nonempty text");
|
|
}
|
|
|
|
async function loadIndex(url, { signal, fetcher = (...args) => scope.fetch(...args) } = {}) {
|
|
const response = await fetcher(url, { signal });
|
|
signal?.throwIfAborted();
|
|
if (!response.ok) throw new Error(`Could not load recipe index: HTTP ${response.status}`);
|
|
const index = await response.json();
|
|
signal?.throwIfAborted();
|
|
const baseUrl = response.url || new URL(url, scope.location?.href).href;
|
|
validateIndex(index, baseUrl);
|
|
return { models: index.models, url: baseUrl };
|
|
}
|
|
|
|
async function loadDeployment(index, id, { signal, fetcher = (...args) => scope.fetch(...args) } = {}) {
|
|
requireShape(object(index) && text(index.url), "Recipe index.url", "an absolute URL");
|
|
validateIndex(index, index.url);
|
|
const deployment = index.models.flatMap((model) => model.deployments).find((item) => item.id === id);
|
|
if (!deployment) throw new Error(`No exported deployment: ${id}`);
|
|
const response = await fetcher(new URL(deployment.catalog_url, index.url).href, { signal });
|
|
signal?.throwIfAborted();
|
|
if (!response.ok) throw new Error(`Could not load deployment: HTTP ${response.status}`);
|
|
const catalog = await response.json();
|
|
signal?.throwIfAborted();
|
|
validateCatalog(catalog, deployment);
|
|
return catalog;
|
|
}
|
|
|
|
/** Quote a shell argument independently of YAML serialization. */
|
|
function shellQuote(value) {
|
|
const text = String(value);
|
|
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(text) ? text : `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
/** Assign a canonical field path into a private copy of the base configuration. */
|
|
function checkPath(path, runtime) {
|
|
const parts = typeof path === "string" ? pathParts(path) : [];
|
|
const roots = ["generator", "server", "default_request"];
|
|
if (runtime.interface === "websocket") roots.push("streaming");
|
|
if (
|
|
parts.length < 2 ||
|
|
!roots.includes(parts[0]) ||
|
|
parts.some(
|
|
(part) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part) || ["__proto__", "constructor", "prototype"].includes(part),
|
|
)
|
|
) {
|
|
throw invalidValue(`Invalid configuration path: ${path}`, path, "path");
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function setPath(config, path, value, runtime) {
|
|
const parts = checkPath(path, runtime);
|
|
let cursor = config;
|
|
for (const part of parts.slice(0, -1)) {
|
|
if (!own(cursor, part)) cursor[part] = {};
|
|
if (!cursor[part] || typeof cursor[part] !== "object" || Array.isArray(cursor[part])) {
|
|
throw invalidValue(`Cannot apply nested field ${path}`, path, "path");
|
|
}
|
|
cursor = cursor[part];
|
|
}
|
|
cursor[parts.at(-1)] = value;
|
|
}
|
|
|
|
const validatorCache = new WeakMap();
|
|
|
|
function getValue(config, path) {
|
|
return pathParts(path).reduce((value, key) => value?.[key], config);
|
|
}
|
|
|
|
const topologyPrefix = "generator.engine.parallelism.";
|
|
const topologyDegrees = ["tp_size", "sp_size", "hsdp_replicate_dim", "hsdp_shard_dim"];
|
|
const autoDegrees = new Set(["tp_size", "sp_size", "hsdp_shard_dim"]);
|
|
|
|
function topologyValues(catalog, config) {
|
|
const defaults = catalog.runtime.topology_defaults;
|
|
if (!defaults) return null; // MLX has no distributed CUDA topology contract.
|
|
const engine = config.generator?.engine;
|
|
return {
|
|
...defaults,
|
|
...engine?.parallelism,
|
|
num_gpus: engine?.num_gpus === undefined ? defaults.num_gpus : engine.num_gpus,
|
|
};
|
|
}
|
|
|
|
/** Match inference auto sentinels and distributed group divisibility without changing user values. */
|
|
function validateTopology(catalog, config) {
|
|
const topology = topologyValues(catalog, config);
|
|
if (!topology) return;
|
|
const gpuCount = topology.num_gpus;
|
|
if (!Number.isInteger(gpuCount) || gpuCount < 1) {
|
|
throw invalidValue("generator.engine.num_gpus must be a positive integer", "generator.engine.num_gpus", "topology");
|
|
}
|
|
for (const key of topologyDegrees) {
|
|
const degree = topology[key],
|
|
path = `${topologyPrefix}${key}`;
|
|
if (degree === -1 && autoDegrees.has(key)) continue;
|
|
if (!Number.isInteger(degree) || degree < 1) {
|
|
throw invalidValue(
|
|
`${path} must be a positive integer${autoDegrees.has(key) ? " or -1 (automatic)" : ""}`,
|
|
path,
|
|
"topology",
|
|
);
|
|
}
|
|
if (degree > gpuCount || gpuCount % degree !== 0) {
|
|
throw invalidValue(
|
|
`${path} must be at most num_gpus (${gpuCount}) and divide num_gpus evenly`,
|
|
path,
|
|
"topology",
|
|
{ relatedPaths: ["generator.engine.num_gpus"] },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function applySelections(catalog, config, selections, validate = false) {
|
|
if (!selections || typeof selections !== "object" || Array.isArray(selections)) {
|
|
throw invalidValue("Selections must be a field-to-value object");
|
|
}
|
|
const controls = new Map(catalog.controls.map((control) => [control.path, control]));
|
|
for (const [path, value] of Object.entries(selections)) {
|
|
const control = controls.get(path);
|
|
if (!control) throw invalidValue(`Field is not an editable recipe control: ${path}`, path, "editable");
|
|
if (validate) validateConfig(control.schema, value, path);
|
|
setPath(config, path, clone(value), catalog.runtime);
|
|
}
|
|
}
|
|
|
|
function validateConfig(schema, config, path) {
|
|
let validate = validatorCache.get(schema);
|
|
if (!validate) {
|
|
validate = createValidator(schema);
|
|
validatorCache.set(schema, validate);
|
|
}
|
|
if (!validate(config)) {
|
|
const messages = validate.errors.map((error) => {
|
|
const parts = error.instancePath
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
const property = error.params.missingProperty ?? error.params.additionalProperty;
|
|
if (property) parts.push(property);
|
|
return `${parts.join(".") || "config"} ${error.message}`;
|
|
});
|
|
throw new CookbookValidationError(`${path}: ${messages.join("; ")}`, path, validate.errors);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize JSON-shaped configuration data as YAML without a browser dependency.
|
|
* Nested mappings use indentation; arrays use JSON syntax, which YAML accepts.
|
|
* String values and mapping keys use JSON quoting so YAML 1.1 readers preserve their types.
|
|
*/
|
|
function yamlValue(value) {
|
|
if (typeof value === "number") {
|
|
if (!Number.isFinite(value)) throw invalidValue("Configuration numbers must be finite");
|
|
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
|
throw invalidValue("Configuration integer exceeds JavaScript's safe integer range");
|
|
}
|
|
// PyYAML needs a decimal point and signed exponent to recognize scientific notation as a float.
|
|
return JSON.stringify(value).replace(
|
|
/^(-?\d+(?:\.\d+)?)e([+-]?)(\d+)$/i,
|
|
(_, mantissa, sign, exponent) =>
|
|
`${mantissa.includes(".") ? mantissa : `${mantissa}.0`}e${sign || "+"}${exponent}`,
|
|
);
|
|
}
|
|
if (Array.isArray(value)) return `[${value.map(yamlValue).join(",")}]`;
|
|
if (value !== null && typeof value === "object") {
|
|
return `{${Object.entries(value)
|
|
.map(([key, item]) => `${JSON.stringify(key)}:${yamlValue(item)}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function toYaml(value, depth = 0) {
|
|
const indent = " ".repeat(depth);
|
|
return Object.entries(value)
|
|
.map(([key, item]) => {
|
|
const yamlKey = JSON.stringify(key);
|
|
const isMapping = item !== null && typeof item === "object" && !Array.isArray(item);
|
|
if (isMapping && Object.keys(item).length) return `${indent}${yamlKey}:\n${toYaml(item, depth + 1)}`;
|
|
return `${indent}${yamlKey}: ${yamlValue(item)}\n`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
/** Build same-machine client instructions; a bind-all address is not a client destination. */
|
|
export function sampleRequest(catalog, config) {
|
|
const server = { ...catalog.runtime.server_defaults, ...config.server };
|
|
let host = server.host;
|
|
if (host === "0.0.0.0") host = "127.0.0.1";
|
|
if (host === "::" || host === "[::]") host = "::1";
|
|
if (host.includes(":") && !host.startsWith("[")) host = `[${host}]`;
|
|
if (catalog.runtime.interface === "websocket") {
|
|
return {
|
|
body: null,
|
|
command: ["curl", "--fail-with-body", `http://${host}:${server.port}/health`].map(shellQuote).join(" "),
|
|
websocketUrl: `ws://${host}:${server.port}/v1/stream`,
|
|
};
|
|
}
|
|
const media = Object.hasOwn(WORKLOADS, catalog.workload) ? WORKLOADS[catalog.workload] : undefined;
|
|
if (media === undefined) throw new CookbookValidationError(`Unsupported workload: ${catalog.workload}`);
|
|
const components = config.generator?.pipeline?.components;
|
|
const model = components?.lora_path ? components.lora_nickname : server.served_model_name || catalog.model.id;
|
|
const body = {
|
|
...(model === undefined ? {} : { model }),
|
|
prompt: config.default_request?.prompt ?? "A river flowing through a peaceful forest",
|
|
};
|
|
if (media.reference === "input_reference") body.input_reference = "/absolute/path/to/first-frame.png";
|
|
if (media.reference === "video_reference") {
|
|
body.video_reference = { video_url: "/absolute/path/to/input.mp4" };
|
|
}
|
|
const argv = [
|
|
"curl",
|
|
"--fail-with-body",
|
|
`http://${host}:${server.port}${media.endpoint}`,
|
|
"-H",
|
|
"Content-Type: application/json",
|
|
"--data",
|
|
JSON.stringify(body),
|
|
];
|
|
argv.push("--output", `output.${media.extension}`);
|
|
return { body, command: argv.map(shellQuote).join(" ") };
|
|
}
|
|
|
|
/**
|
|
* Keep every explicit native recipe setting, then apply edits only to selected controls.
|
|
* Omitted fields remain visibly inherited; declared defaults are not effective runtime values.
|
|
* Reset is resolveConfig(catalog, {}); explicit false, zero, null and arrays survive.
|
|
* Example: {"server.port": 9000} changes only the recipe port; hidden VSA settings remain.
|
|
*/
|
|
function resolveConfig(catalog, selections = {}) {
|
|
const config = clone(catalog.base_config);
|
|
applySelections(catalog, config, selections, true);
|
|
for (const control of catalog.controls) {
|
|
const value = getValue(config, control.path);
|
|
if (value !== undefined) {
|
|
validateConfig(control.schema, value, control.path);
|
|
}
|
|
}
|
|
validateTopology(catalog, config);
|
|
const argv = [...catalog.runtime.launch_argv];
|
|
const environment = Object.entries(catalog.env).map(([key, value]) => `${key}=${shellQuote(value)}`);
|
|
const sample = sampleRequest(catalog, config);
|
|
return {
|
|
config,
|
|
yaml: toYaml(config),
|
|
argv,
|
|
command: [...environment, ...argv.map(shellQuote)].join(" "),
|
|
clientCommand: sample.command,
|
|
clientRequest: sample.body,
|
|
...(sample.websocketUrl ? { websocketUrl: sample.websocketUrl } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return metadata/current values, including degree limits derived from the selected GPU count.
|
|
* Optional edits preview incomplete configurations without validation; resolveConfig validates output.
|
|
* Neither previews nor declared defaults insert inherited fields into saved configuration.
|
|
*/
|
|
function getOptions(catalog, config = catalog.base_config, selections = {}) {
|
|
const preview = clone(config);
|
|
applySelections(catalog, preview, selections);
|
|
const topology = topologyValues(catalog, preview);
|
|
return catalog.controls.map(({ path, schema }) => {
|
|
const field = clone(schema),
|
|
value = getValue(preview, path);
|
|
const key = path.startsWith(topologyPrefix) ? path.slice(topologyPrefix.length) : null;
|
|
if (topology && topologyDegrees.includes(key)) {
|
|
field.minimum = Math.max(field.minimum ?? -Infinity, autoDegrees.has(key) ? -1 : 1);
|
|
if (Number.isInteger(topology.num_gpus) && topology.num_gpus > 0) {
|
|
field.maximum = Math.min(field.maximum ?? Infinity, topology.num_gpus);
|
|
}
|
|
const rule = { not: { const: 0 } };
|
|
if (autoDegrees.has(key)) field.allOf = [...(field.allOf || []), rule];
|
|
field.description = [
|
|
field.description,
|
|
`Positive values must divide num_gpus evenly${autoDegrees.has(key) ? "; -1 selects automatic sizing" : ""}.`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
} else if (topology && path === "generator.engine.num_gpus") {
|
|
field.minimum = Math.max(field.minimum ?? -Infinity, 1);
|
|
}
|
|
return { path, schema: field, value: value === undefined ? undefined : clone(value) };
|
|
});
|
|
}
|
|
|
|
export { loadIndex, loadDeployment, getOptions, resolveConfig };
|
|
export {
|
|
assessCapacity,
|
|
estimateMemory,
|
|
matchEvidence,
|
|
recommendCapacity,
|
|
resolveExecution,
|
|
} from "./cookbook-memory.mjs";
|