diff --git a/bin/lloom.mjs b/bin/lloom.mjs index 06b8b49..83a3d45 100755 --- a/bin/lloom.mjs +++ b/bin/lloom.mjs @@ -70,7 +70,7 @@ import { stopGatewayService, uninstallGatewayService } from '../src/service-install.mjs'; -import { addAuthenticatedNode, readBoundedStdinCredential } from '../src/node-onboarding.mjs'; +import { addAuthenticatedNode, parsePositiveTimeoutMs, readBoundedStdinCredential } from '../src/node-onboarding.mjs'; import { retargetGatewayRelay } from '../src/gateway-relay.mjs'; import { retargetClientFile } from '../src/client-retarget.mjs'; import { applyModelImport, applyModelImportGo } from '../src/model-intake.mjs'; @@ -246,7 +246,7 @@ Backends and runtimes: lloom deployment rollback --plan FILE --nodes FILE --operation-id ID --apply --yes [--generation N] [--journal FILE] [--json] deployment plans require the reviewed archive and manifest at exact absolute paths on every target lloom node-agent --json # authenticated remote gateway release phase; request JSON on stdin - lloom cluster add-node [--namespace NAME|--merge] [--include-external] [--api-key-env NAME|--api-key-stdin] [--telemetry-only] [--apply --yes] + lloom cluster add-node [--namespace NAME|--merge] [--include-external] [--api-key-env NAME|--api-key-stdin] [--timeout-ms N] [--telemetry-only] [--apply --yes] lloom service install [--enable-linger] [--apply --yes] [--json] lloom service doctor [--json] lloom service stop [--apply --yes] [--json] @@ -1720,6 +1720,7 @@ async function main() { }, init: async ({ args, config, command: _command }) => { const recipeId = argValue(args, '--recipe'); + const recipesRoot = argValue(args, '--recipes-root'); const home = argValue(args, '--home') ?? process.env.HOME; const configOut = argValue(args, '--config-out') ?? defaultUserConfigPath(home); const modelRoot = argValue(args, '--model-root'); @@ -1748,6 +1749,7 @@ async function main() { integrate, additive, backendVariables: backendVariablesForCli(args), + ...(recipesRoot ? { recipesRoot } : {}), backendCatalogPath: argValue(args, '--backend-catalog') }), null, @@ -1758,6 +1760,7 @@ async function main() { bootstrap: async ({ args, config, command: _command }) => { const recipeId = argValue(args, '--recipe'); const modelRoot = argValue(args, '--model-root'); + const recipesRoot = argValue(args, '--recipes-root'); const clientId = argValue(args, '--client') ?? 'all'; const statePath = argValue(args, '--state'); const apply = hasFlag(args, '--apply'); @@ -1770,6 +1773,7 @@ async function main() { modelRoot, clientId, backendVariables: backendVariablesForCli(args), + ...(recipesRoot ? { recipesRoot } : {}), benchmarksRoot: argValue(args, '--benchmarks-root'), backendCatalogPath: argValue(args, '--backend-catalog') }), @@ -1785,6 +1789,7 @@ async function main() { recipeId, modelRoot, clientId, + ...(recipesRoot ? { recipesRoot } : {}), dryRun: false, yes, ...(statePath ? { statePath } : {}), @@ -2343,7 +2348,7 @@ async function main() { process.exitCode = 2; return; } - const recipe = await loadRecipeById(recipeId); + const recipe = await loadRecipeById(recipeId, argValue(args, '--recipes-root')); const catalog = await loadBackendCatalog(argValue(args, '--backend-catalog')); const modelRoot = argValue(args, '--model-root') ?? '${LLOOM_MODEL_ROOT}'; const { @@ -2374,7 +2379,7 @@ async function main() { process.exitCode = 2; return; } - const recipe = await loadRecipeById(recipeId); + const recipe = await loadRecipeById(recipeId, argValue(args, '--recipes-root')); const modelRoot = argValue(args, '--model-root') ?? process.env.LLOOM_MODEL_ROOT; const statePath = argValue(args, '--state'); const onlyStep = argValue(args, '--step'); @@ -2715,11 +2720,15 @@ async function main() { const endpoint = positional(args)[3]; if (!nodeId || !endpoint) { throw new Error( - 'Usage: lloom cluster add-node [--namespace NAME|--merge] [--include-external] [--api-key-env NAME|--api-key-stdin] [--telemetry-only] [--apply --yes]' + 'Usage: lloom cluster add-node [--namespace NAME|--merge] [--include-external] [--api-key-env NAME|--api-key-stdin] [--timeout-ms N] [--telemetry-only] [--apply --yes]' ); } if (hasFlag(args, '--api-key-stdin') && argValue(args, '--api-key-env')) throw new Error('Choose --api-key-stdin or --api-key-env'); + const timeoutArg = argValue(args, '--timeout-ms'); + if (hasFlag(args, '--timeout-ms') && timeoutArg === undefined) + throw new Error('--timeout-ms requires a positive integer'); + const timeoutMs = parsePositiveTimeoutMs(timeoutArg); const apiKeyEnv = hasFlag(args, '--api-key-stdin') ? null : (argValue(args, '--api-key-env') ?? config.cluster?.apiKeyEnv ?? 'LLOOM_CLUSTER_KEY'); @@ -2737,6 +2746,7 @@ async function main() { namespace: argValue(args, '--namespace') ?? nodeId, merge: hasFlag(args, '--merge'), includeExternal: hasFlag(args, '--include-external'), + timeoutMs, apply: hasFlag(args, '--apply'), yes: hasFlag(args, '--yes') }), diff --git a/docs/clusters.md b/docs/clusters.md index 3034430..8e29ed8 100644 --- a/docs/clusters.md +++ b/docs/clusters.md @@ -17,11 +17,13 @@ Expose the joining LLooM only on a trusted private network, require authenticati ```bash export LLOOM_CLUSTER_KEY='the key accepted by the joining LLooM' -lloom cluster add-node macbook-local http://macbook-private:8100 --apply --yes +lloom cluster add-node macbook-local http://macbook-private:8100 --timeout-ms 3600000 --apply --yes ``` The command reads the joining node's `/gateway/node` snapshot, records its architecture, accelerators, memory, and directly hosted model catalog, and creates deterministic gateway names such as `macbook-local/local/qwen`. Imported targets retain the joining gateway's runtime ID as observational metadata, so the central topology and replica selection can reflect remote lifecycle and load without taking ownership of that process. It never copies credentials into config: the node and generated backend retain only `apiKeyEnv`. Config reload activates the node without restarting the central gateway. +`--timeout-ms` sets the positive-integer request deadline for the joining node's federation proxy and updates its existing dedicated backend timeout while preserving the backend URL and authentication fields. If another node shares that backend ID, the apply is refused rather than changing a shared deadline. Omitting the option preserves an existing timeout. + The declarative form is useful for review or hand editing: ```json diff --git a/package.json b/package.json index a943c2e..9073cee 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "community:check": "node scripts/check-community-deploy.mjs", "package:check": "node scripts/check-package.mjs", "test:adoption": "node test/resource-fit.test.mjs && node test/model-acquisition.test.mjs && node test/dashboard-status.test.mjs", - "test:unit": "node --test test/deployment-fence.test.mjs test/cluster-deployment.test.mjs test/cluster-deployment-cli.test.mjs test/cluster-deployment-e2e.test.mjs test/node-release-agent.test.mjs test/node-gateway-adapter.test.mjs test/node-gateway-adapter-e2e.test.mjs test/deployment-bundle.test.mjs test/node-agent-cli.test.mjs test/ssh-deployment-transport.test.mjs && node test/runtime-health-ownership.test.mjs && node test/long-prefill-transport.test.mjs && node test/sparkglm-managed.test.mjs && node test/protocol.test.mjs && node test/protocol-stream.test.mjs && node test/stream-metrics.test.mjs && node test/responses-codex.test.mjs && node test/security.test.mjs && node test/community-host.test.mjs && node test/ds4fv-recipe.test.mjs && node test/dspark-hotfixes.test.mjs && node test/glm53-exl3-recipe.test.mjs && node test/host-memory.test.mjs && node test/config-profiles.test.mjs && node test/runtime-policy.test.mjs && node --test test/runtime-policy-config.test.mjs && node --test test/request-scheduler.test.mjs test/adaptive-priority.test.mjs test/rate-limit.test.mjs test/runtime-queue.test.mjs test/foreground-priority.test.mjs test/federated-priority.test.mjs test/runtime-supervisor.test.mjs && node test/interchange.test.mjs && node test/server-resilience.test.mjs && node --test test/standby.test.mjs && node test/metrics-persistence.test.mjs && node test/model-failover.test.mjs && node test/qwen38-vllm-recipe.test.mjs && node test/reasoning-normalize.test.mjs && node test/reasoning-effort.test.mjs && node test/route-control.test.mjs && node test/spark-route-catalog.test.mjs && node test/chat-lane-canary.test.mjs && node test/runtime-watchdog.test.mjs && node test/tts-catalog.test.mjs && node test/voice-profiles.test.mjs && node test/chatterbox-recipe.test.mjs && node test/hear-recipe.test.mjs && npm run test:adoption && node --test test/performance-sampler.test.mjs && node --test test/audio-generations.test.mjs && node test/video-providers.test.mjs && node --test test/web-functions.test.mjs && node --test test/config-mutation.test.mjs && node --test test/model-maintenance.test.mjs && node --test test/model-maintenance-control.test.mjs && node --test test/route-maintenance-concurrency.test.mjs && node --test test/model-maintenance-integration.test.mjs && node --test test/schedule.test.mjs test/schedule-integration.test.mjs && node test/comfyui-media.test.mjs && node test/qwen-image-diffusers.test.mjs && node test/ace-step-diffusers.test.mjs && node test/runtime-residency.test.mjs && node test/spark-audio-recipes.test.mjs && node --test test/video-contracts.test.mjs && node test/ltx-pipelines-recipe.test.mjs && npm run test:ux && npm run test:memory-safety && node --test test/dashboard-memory.test.mjs test/runtime-memory-usage.test.mjs && node --test test/speech-stream.test.mjs && node --test test/audio-providers.test.mjs && node test/openrouter-provider.test.mjs && node test/atlas-sparkglm-recipe.test.mjs && node test/atlas-sparkqwen-recipe.test.mjs && node --test test/head-preparation.test.mjs test/head-transfer.test.mjs test/service-control.test.mjs && node --test test/gateway-relay.test.mjs test/client-retarget.test.mjs && node --test test/head-profile-acceptance.test.mjs test/head-promotion.test.mjs test/head-promotion-transfer.test.mjs test/head-transfer-acceptance.test.mjs && node --test test/cli-diagnostics.test.mjs && node --test test/service-install.test.mjs", + "test:unit": "node --test test/deployment-fence.test.mjs test/cluster-deployment.test.mjs test/cluster-deployment-cli.test.mjs test/cluster-deployment-e2e.test.mjs test/node-release-agent.test.mjs test/node-gateway-adapter.test.mjs test/node-gateway-adapter-e2e.test.mjs test/deployment-bundle.test.mjs test/node-agent-cli.test.mjs test/ssh-deployment-transport.test.mjs && node test/runtime-health-ownership.test.mjs && node test/long-prefill-transport.test.mjs && node test/sparkglm-managed.test.mjs && node test/protocol.test.mjs && node test/protocol-stream.test.mjs && node test/stream-metrics.test.mjs && node test/responses-codex.test.mjs && node test/security.test.mjs && node test/community-host.test.mjs && node test/ds4fv-recipe.test.mjs && node test/dspark-hotfixes.test.mjs && node test/glm53-exl3-recipe.test.mjs && node test/host-memory.test.mjs && node test/config-profiles.test.mjs && node test/runtime-policy.test.mjs && node --test test/runtime-policy-config.test.mjs && node --test test/request-scheduler.test.mjs test/adaptive-priority.test.mjs test/rate-limit.test.mjs test/runtime-queue.test.mjs test/foreground-priority.test.mjs test/federated-priority.test.mjs test/runtime-supervisor.test.mjs && node test/interchange.test.mjs && node test/server-resilience.test.mjs && node --test test/standby.test.mjs && node test/metrics-persistence.test.mjs && node test/model-failover.test.mjs && node test/qwen38-vllm-recipe.test.mjs && node test/reasoning-normalize.test.mjs && node test/reasoning-effort.test.mjs && node test/route-control.test.mjs && node test/spark-route-catalog.test.mjs && node test/chat-lane-canary.test.mjs && node test/runtime-watchdog.test.mjs && node test/tts-catalog.test.mjs && node test/voice-profiles.test.mjs && node test/chatterbox-recipe.test.mjs && node test/hear-recipe.test.mjs && npm run test:adoption && node --test test/performance-sampler.test.mjs && node --test test/audio-generations.test.mjs && node test/video-providers.test.mjs && node --test test/web-functions.test.mjs && node --test test/config-mutation.test.mjs && node --test test/model-maintenance.test.mjs && node --test test/model-maintenance-control.test.mjs && node --test test/route-maintenance-concurrency.test.mjs && node --test test/model-maintenance-integration.test.mjs && node --test test/schedule.test.mjs test/schedule-integration.test.mjs && node test/comfyui-media.test.mjs && node test/qwen-image-diffusers.test.mjs && node test/ace-step-diffusers.test.mjs && node test/runtime-residency.test.mjs && node test/spark-audio-recipes.test.mjs && node --test test/video-contracts.test.mjs && node test/ltx-pipelines-recipe.test.mjs && npm run test:ux && npm run test:memory-safety && node --test test/dashboard-memory.test.mjs test/runtime-memory-usage.test.mjs && node --test test/speech-stream.test.mjs && node --test test/audio-providers.test.mjs && node test/openrouter-provider.test.mjs && node test/atlas-sparkglm-recipe.test.mjs && node test/atlas-sparkqwen-recipe.test.mjs && node --test test/head-preparation.test.mjs test/head-transfer.test.mjs test/service-control.test.mjs && node --test test/gateway-relay.test.mjs test/client-retarget.test.mjs && node --test test/head-profile-acceptance.test.mjs test/head-promotion.test.mjs test/head-promotion-transfer.test.mjs test/head-transfer-acceptance.test.mjs && node --test test/cli-diagnostics.test.mjs test/init-backend-timeout.test.mjs test/recipes-root-cli.test.mjs && node --test test/service-install.test.mjs", "test:cluster": "node test/cluster.test.mjs && node --test test/cluster-availability.test.mjs test/cluster-readiness.test.mjs", "test": "npm run test:cluster && npm run test:unit && npm run test:entity-stagger && npm run smoke && npm run community:check", "test:entity-stagger": "node test/entity-stagger-benchmark.test.mjs", diff --git a/src/cluster.mjs b/src/cluster.mjs index 5771a98..5e4aecb 100644 --- a/src/cluster.mjs +++ b/src/cluster.mjs @@ -45,6 +45,30 @@ function slug(value) { ); } +export function federatedBackendId(nodeId, node = {}) { + return asObject(node?.proxy).backend ?? `lloom-node-${slug(nodeId)}`; +} + +function sharedFederatedBackendNodeIds(config, nodeId, backendId) { + return Object.entries(asObject(config.cluster?.nodes)) + .filter( + ([candidateId, candidate]) => candidateId !== nodeId && federatedBackendId(candidateId, candidate) === backendId + ) + .map(([candidateId]) => candidateId); +} + +export function updateFederatedBackendTimeout(config, { nodeId, node, timeoutMs } = {}) { + const backendId = federatedBackendId(nodeId, node); + const sharedNodeIds = sharedFederatedBackendNodeIds(config, nodeId, backendId); + if (sharedNodeIds.length) return { backendId, updated: false, sharedNodeIds }; + const backend = config.backends?.[backendId]; + if (!backend || typeof backend !== 'object' || Array.isArray(backend)) { + return { backendId, updated: false, sharedNodeIds: [] }; + } + backend.timeoutMs = timeoutMs; + return { backendId, updated: true, sharedNodeIds: [] }; +} + function proxyModels(node = {}) { const proxy = asObject(node.proxy); return (Array.isArray(proxy.models) ? proxy.models : []).map((entry) => @@ -61,7 +85,7 @@ export function isFederatedGatewayBackend(config, backendId) { proxy.enabled !== false && proxyModels(node).length > 0 && Boolean(node.endpoint || proxy.baseUrl) && - backendId === (proxy.backend ?? `lloom-node-${slug(nodeId)}`) + backendId === federatedBackendId(nodeId, node) ); }); } @@ -74,7 +98,7 @@ export function materializeFederatedNodes(config) { const advertised = proxyModels(node); if (proxy.enabled === false || !advertised.length) continue; if (!node.endpoint && !proxy.baseUrl) continue; - const backendId = proxy.backend ?? `lloom-node-${slug(nodeId)}`; + const backendId = federatedBackendId(nodeId, node); config.backends[backendId] ??= { type: 'openai', baseUrl: proxy.baseUrl ?? `${trimSlash(node.endpoint)}/v1`, diff --git a/src/init.mjs b/src/init.mjs index b12d56c..85943b5 100644 --- a/src/init.mjs +++ b/src/init.mjs @@ -142,6 +142,24 @@ function urlWithPort(port, suffix = '/v1') { return `http://127.0.0.1:${port}${suffix}`; } +// An explicit backend port range is the operator's allocation boundary for +// this init run. Use it while materializing new recipe runtimes, before the +// later retarget pass rewrites the generated config. Without this, a config +// whose old range is exhausted fails before --backend-port-range can take +// effect. +function nextInitBackendPort(config, backendPortRange) { + if (!backendPortRange) return nextBackendPort(config); + const range = normalizeBackendPortRange(backendPortRange); + const allocationConfig = { + ...config, + ports: { + ...(config.ports ?? {}), + backends: range + } + }; + return nextBackendPort(allocationConfig); +} + function isPathLike(value) { return typeof value === 'string' && (value.startsWith('/') || value.startsWith('.')); } @@ -608,7 +626,11 @@ function buildRecipeRuntime({ return null; } -function ensureRecipeConfigEntries(config, recipe, { modelRoot, sessionCacheRoot, backendVariables } = {}) { +function ensureRecipeConfigEntries( + config, + recipe, + { modelRoot, sessionCacheRoot, backendVariables, backendPortRange } = {} +) { const backendId = recipe.backend?.id; if (!backendId) return; config.backends ??= {}; @@ -839,7 +861,8 @@ function ensureRecipeConfigEntries(config, recipe, { modelRoot, sessionCacheRoot const runtimeId = `${recipeModel.runtime ?? `${backendId}-${modelSlug}`}-${suffix}`; const backendConfigId = `${recipeModel.backendConfig ?? `${backendId}-${modelSlug}`}-${suffix}`; const port = - Number(config.runtimes?.[runtimeId]?.port) || positiveInteger(settings.port, nextBackendPort(config)); + Number(config.runtimes?.[runtimeId]?.port) || + positiveInteger(settings.port, nextInitBackendPort(config, backendPortRange)); const backendOrigin = clusterNodeBackendOrigin(node); const baseUrlPath = asObject(settings.runtime).baseUrlPath ?? settings.baseUrlPath ?? '/v1'; config.backends[backendConfigId] = { @@ -913,7 +936,7 @@ function ensureRecipeConfigEntries(config, recipe, { modelRoot, sessionCacheRoot const retired = leavingSharedMedia ? { runtime: existingModel.runtime, backend: existingModel.backend } : null; const runtimeId = inherited?.runtime ?? recipeModel.runtime ?? `${backendId}-${modelSlug}`; const backendConfigId = inherited?.backend ?? recipeModel.backendConfig ?? `${backendId}-${modelSlug}`; - const port = Number(config.runtimes?.[runtimeId]?.port) || nextBackendPort(config); + const port = Number(config.runtimes?.[runtimeId]?.port) || nextInitBackendPort(config, backendPortRange); const modelPath = modelPathForRecipeModel(recipeModel, backendId, modelRoot); if (!config.backends[backendConfigId]) { @@ -927,6 +950,15 @@ function ensureRecipeConfigEntries(config, recipe, { modelRoot, sessionCacheRoot } const existingRuntime = config.runtimes[runtimeId]; + const requestedTimeoutMs = explicitBackendTimeoutMs(settings); + if ( + config.backends[backendConfigId] && + requestedTimeoutMs !== null && + existingRuntime?.recipe?.id === recipe.id && + !hasUnrelatedBackendReferences(config, backendConfigId, { modelId, runtimeId }) + ) { + config.backends[backendConfigId].timeoutMs = requestedTimeoutMs; + } if (!existingRuntime || existingRuntime.recipe?.id === recipe.id) { const runtime = buildRecipeRuntime({ recipe, @@ -937,7 +969,8 @@ function ensureRecipeConfigEntries(config, recipe, { modelRoot, sessionCacheRoot modelRoot, modelId, port, - sessionCacheRoot + sessionCacheRoot, + backendVariables }); if (runtime) config.runtimes[runtimeId] = runtime; } @@ -998,6 +1031,32 @@ function isSharedMediaRuntime(config, runtimeId) { return Array.isArray(createArgs) && !createArgs.some((arg) => String(arg).startsWith('LLOOM_MEDIA_MODEL=')); } +// A recipe may reuse a backend entry that was materialized by an earlier init. +// Refreshing only the timeout is safe when this recipe owns the runtime and no +// other model/runtime depends on that backend. Shared routes may carry a +// different operator contract, so leave them untouched. +function hasUnrelatedBackendReferences(config, backendId, { modelId, runtimeId } = {}) { + for (const model of config.models ?? []) { + if (model?.backend === backendId && !(model.id === modelId && model.runtime === runtimeId)) return true; + for (const target of asArray(model?.targets)) { + if (target?.backend !== backendId) continue; + if (!(model.id === modelId && target.runtime === runtimeId)) return true; + } + } + + for (const [candidateRuntimeId, runtime] of Object.entries(config.runtimes ?? {})) { + const runtimeBackends = [runtime?.backend, runtime?.upstreamBackend, ...asArray(runtime?.backends)].filter(Boolean); + if (runtimeBackends.includes(backendId) && candidateRuntimeId !== runtimeId) return true; + } + return false; +} + +function explicitBackendTimeoutMs(settings) { + if (!Object.hasOwn(settings, 'timeoutMs')) return null; + const timeoutMs = Number(settings.timeoutMs); + return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : null; +} + function dropUnreferencedRoute(config, { runtime, backend }) { const routes = config.models.flatMap((model) => [model, ...asArray(model.targets)]); if (runtime && !routes.some((route) => route.runtime === runtime)) delete config.runtimes[runtime]; @@ -1316,18 +1375,24 @@ function initCommand({ gatewayPort, backendPortRange, recipeId, + recipesRoot, + backendCatalogPath, clientId, home, generatedRoot, apply = false, - integrate = false + integrate = false, + additive = false } = {}) { const args = ['lloom', 'init']; if (recipeId) args.push('--recipe', shellArg(recipeId)); + if (additive) args.push('--additive'); args.push(...customHomeArg(home)); if (generatedRoot) args.push('--generated-root', shellArg(generatedRoot)); if (configPath) args.push('--config-out', shellArg(configPath)); if (modelRoot) args.push('--model-root', shellArg(modelRoot)); + if (recipesRoot) args.push('--recipes-root', shellArg(recipesRoot)); + if (backendCatalogPath) args.push('--backend-catalog', shellArg(backendCatalogPath)); if (gatewayPort) args.push('--port', shellArg(gatewayPort)); if (backendPortRange) args.push('--backend-port-range', shellArg(backendPortRange)); if (clientId && clientId !== 'all') args.push('--client', shellArg(clientId)); @@ -1370,7 +1435,8 @@ export function deriveUserConfig( ensureRecipeConfigEntries(derived, recipe, { modelRoot, sessionCacheRoot, - backendVariables + backendVariables, + backendPortRange }); const materializedRuntimeIds = Object.entries(derived.runtimes ?? {}) .filter(([, runtime]) => runtime.recipe?.id === recipe.id) @@ -1563,16 +1629,21 @@ export async function createInitPlan( next: { review: initCommand({ recipeId: recipe.id, + recipesRoot, + backendCatalogPath, configPath: effectiveConfigPath, modelRoot: effectiveModelRoot, gatewayPort, backendPortRange, clientId, home, - generatedRoot + generatedRoot, + additive }), apply: initCommand({ recipeId: recipe.id, + recipesRoot, + backendCatalogPath, configPath: effectiveConfigPath, modelRoot: effectiveModelRoot, gatewayPort, @@ -1580,10 +1651,13 @@ export async function createInitPlan( clientId, home, generatedRoot, - apply: true + apply: true, + additive }), integrate: initCommand({ recipeId: recipe.id, + recipesRoot, + backendCatalogPath, configPath: effectiveConfigPath, modelRoot: effectiveModelRoot, gatewayPort, @@ -1592,9 +1666,18 @@ export async function createInitPlan( home, generatedRoot, apply: true, - integrate: true + integrate: true, + additive }), - bootstrap: `lloom bootstrap --config ${shellArg(effectiveConfigPath)} --apply --yes`, + bootstrap: [ + 'lloom bootstrap', + '--config', + shellArg(effectiveConfigPath), + ...(recipesRoot ? ['--recipe', shellArg(recipe.id), '--recipes-root', shellArg(recipesRoot)] : []), + ...(backendCatalogPath ? ['--backend-catalog', shellArg(backendCatalogPath)] : []), + '--apply', + '--yes' + ].join(' '), serve: `lloom serve --config ${shellArg(effectiveConfigPath)}`, pathHint: `export PATH="${backendVariables.shimDir}:$PATH"` }, diff --git a/src/node-onboarding.mjs b/src/node-onboarding.mjs index 5497232..0be378a 100644 --- a/src/node-onboarding.mjs +++ b/src/node-onboarding.mjs @@ -1,7 +1,20 @@ import { promises as fs } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { mutateConfigSource } from './config-mutation.mjs'; -import { currentNodeId, federatedNodeConfigFromSnapshot } from './cluster.mjs'; +import { currentNodeId, federatedNodeConfigFromSnapshot, updateFederatedBackendTimeout } from './cluster.mjs'; + +export function parsePositiveTimeoutMs(value) { + if (value == null || value === '') return undefined; + const text = String(value); + if (!/^[1-9][0-9]*$/.test(text)) throw new Error('--timeout-ms must be a positive integer'); + const timeoutMs = Number(text); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error('--timeout-ms must be a positive integer'); + return timeoutMs; +} + +function objectRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} export async function readBoundedStdinCredential(stream, { maxBytes = 16384 } = {}) { const chunks = []; @@ -32,9 +45,11 @@ export async function addAuthenticatedNode( includeExternal = false, apply = false, yes = false, + timeoutMs: requestedTimeoutMs, fetchFn = fetch } = {} ) { + const timeoutMs = parsePositiveTimeoutMs(requestedTimeoutMs); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(nodeId ?? '')) throw new Error('Invalid node identity'); if (nodeId === currentNodeId(config)) throw new Error('Cannot replace the local node with a federation peer'); let url; @@ -79,13 +94,15 @@ export async function addAuthenticatedNode( node.apiKey = apiKey; } if (telemetryOnly) node.proxy = { enabled: false, models: [] }; + if (timeoutMs !== undefined) node.proxy.timeoutMs = timeoutMs; const summary = { ok: true, applied: false, nodeId, endpoint: base, telemetryOnly, - importedModels: node.proxy.models.length + importedModels: node.proxy.models.length, + ...(timeoutMs === undefined ? {} : { timeoutMs }) }; if (!apply) return summary; const before = await fs.readFile(config.sourcePath, 'utf8'); @@ -94,6 +111,8 @@ export async function addAuthenticatedNode( const backupPath = config.sourcePath + '.lloom-before-' + randomUUID(); await fs.writeFile(backupPath, before, { flag: 'wx', mode: 0o600 }); await fs.chmod(backupPath, 0o600); + let backendId; + let backendTimeoutUpdated = false; const { changed } = await mutateConfigSource(config, (raw) => { if (JSON.stringify(raw) !== JSON.stringify(JSON.parse(before))) throw new Error('Configuration changed after the reviewed snapshot'); @@ -102,7 +121,51 @@ export async function addAuthenticatedNode( raw.cluster.nodeId ??= local; raw.cluster.leaderNode ??= local; raw.cluster.nodes ??= { [local]: { name: local } }; - raw.cluster.nodes[nodeId] = node; + const existingNode = objectRecord(raw.cluster.nodes[nodeId]); + const existingProxy = objectRecord(existingNode.proxy); + const nextNode = { + ...existingNode, + ...node, + labels: { + ...objectRecord(existingNode.labels), + ...objectRecord(node.labels) + }, + resources: { + ...objectRecord(existingNode.resources), + ...objectRecord(node.resources) + }, + proxy: { + ...existingProxy, + ...objectRecord(node.proxy) + } + }; + if (!telemetryOnly && !Object.hasOwn(node.proxy, 'enabled') && existingProxy.enabled === false) { + delete nextNode.proxy.enabled; + } + if (apiKeyEnv == null && apiKey) { + delete nextNode.apiKeyEnv; + nextNode.apiKey = apiKey; + } else if (apiKeyEnv) { + delete nextNode.apiKey; + nextNode.apiKeyEnv = apiKeyEnv; + } + if (timeoutMs !== undefined) { + const update = updateFederatedBackendTimeout(raw, { nodeId, node: nextNode, timeoutMs }); + if (update.sharedNodeIds.length) { + throw new Error( + `Cannot set --timeout-ms for ${nodeId}: federation backend ${update.backendId} is shared by ${update.sharedNodeIds.join(', ')}` + ); + } + backendId = update.backendId; + backendTimeoutUpdated = update.updated; + } + raw.cluster.nodes[nodeId] = nextNode; }); - return { ...summary, applied: true, changed, backupPath }; + return { + ...summary, + applied: true, + changed, + backupPath, + ...(timeoutMs === undefined ? {} : { backendId, backendTimeoutUpdated }) + }; } diff --git a/test/cli-diagnostics.test.mjs b/test/cli-diagnostics.test.mjs index 9cbf2fd..b95b54f 100644 --- a/test/cli-diagnostics.test.mjs +++ b/test/cli-diagnostics.test.mjs @@ -496,3 +496,56 @@ test('service doctor loads explicit environment file before templated config and assert.deepEqual(seen, ['Bearer custom-file-secret', 'Bearer custom-file-secret']); assert.doesNotMatch(child.stdout + child.stderr, /custom-file-secret/); }); + +test('advanced help documents the federation proxy timeout option', async () => { + const result = await runCli(['help', 'advanced']); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /cluster add-node .*--timeout-ms N/); +}); + +test('cluster add-node applies timeout-ms through the CLI', async (t) => { + const seen = []; + const server = http.createServer((request, response) => { + seen.push({ url: request.url, authorization: request.headers.authorization }); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + node: { + id: 'peer', + profile: { platformId: 'darwin-arm64', totalMemoryGb: 96, accelerators: ['metal'] }, + models: [{ id: 'qwen', runtime: 'qwen-runtime' }] + } + }) + ); + }); + const port = await listen(server); + const home = await writeConfigFixture({ + server: { host: '127.0.0.1', port: 8100 }, + cluster: { nodeId: 'head', leaderNode: 'head', nodes: { head: {} } }, + backends: {}, + models: [], + security: { apiKeys: [] } + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + t.after(() => fs.rm(home, { recursive: true, force: true })); + const result = await runCli( + [ + 'cluster', + 'add-node', + 'peer', + `http://127.0.0.1:${port}`, + '--timeout-ms', + '3600000', + '--apply', + '--yes', + '--json' + ], + { home, env: { LLOOM_CLUSTER_KEY: 'fixture-cluster-key' } } + ); + assert.equal(result.code, 0, result.stderr); + const receipt = JSON.parse(result.stdout); + assert.equal(receipt.timeoutMs, 3_600_000); + const current = JSON.parse(await fs.readFile(path.join(home, '.lloom', 'config.json'), 'utf8')); + assert.equal(current.cluster.nodes.peer.proxy.timeoutMs, 3_600_000); + assert.deepEqual(seen, [{ url: '/gateway/node', authorization: 'Bearer fixture-cluster-key' }]); +}); diff --git a/test/cluster.test.mjs b/test/cluster.test.mjs index d00d704..c25e289 100644 --- a/test/cluster.test.mjs +++ b/test/cluster.test.mjs @@ -4,6 +4,7 @@ import { buildNvidiaSyncDiscovery, ClusterCoordinator, clusterNodes, + federatedBackendId, federatedNodeConfigFromSnapshot, materializeFederatedNodes, mergeNvidiaSyncClusterDiscovery, @@ -14,6 +15,7 @@ import { runtimeAuthority, runtimeControlAllowed, runtimePlacement, + updateFederatedBackendTimeout, validateClusterConfig } from '../src/cluster.mjs'; import { @@ -106,6 +108,7 @@ materializeFederatedNodes(heterogeneous); assert.equal(heterogeneous.backends['lloom-node-macbook'].baseUrl, 'http://macbook:8100/v1'); assert.equal(heterogeneous.backends['lloom-node-macbook'].apiKeyEnv, 'LAB_KEY'); assert.equal(heterogeneous.models.find((model) => model.id === 'macbook/local/embed').kind, 'embedding'); +assert.equal(federatedBackendId('macbook', heterogeneous.cluster.nodes.macbook), 'lloom-node-macbook'); const replicated = heterogeneous.models.find((model) => model.id === 'lab/qwen'); assert.deepEqual( modelTargets(replicated).map((target) => [target.node, target.upstreamModel, target.remoteRuntime, target.weight]), @@ -116,6 +119,45 @@ assert.deepEqual( ); assert.deepEqual(validateClusterConfig(heterogeneous, { LLOOM_NODE_ID: 'spark' }), []); +const federationTimeoutConfig = { + cluster: { + nodeId: 'head', + nodes: { + head: {}, + peer: { proxy: { backend: 'peer-federation', models: [{ id: 'remote' }] } } + } + }, + backends: { + 'peer-federation': { + type: 'openai', + baseUrl: 'http://peer.test:8100/v1', + apiKeyEnv: 'PEER_KEY', + timeoutMs: 1_800_000 + } + } +}; +const timeoutUpdate = updateFederatedBackendTimeout(federationTimeoutConfig, { + nodeId: 'peer', + node: federationTimeoutConfig.cluster.nodes.peer, + timeoutMs: 3_600_000 +}); +assert.deepEqual(timeoutUpdate, { backendId: 'peer-federation', updated: true, sharedNodeIds: [] }); +assert.equal(federationTimeoutConfig.backends['peer-federation'].baseUrl, 'http://peer.test:8100/v1'); +assert.equal(federationTimeoutConfig.backends['peer-federation'].apiKeyEnv, 'PEER_KEY'); +assert.equal(federationTimeoutConfig.backends['peer-federation'].timeoutMs, 3_600_000); +federationTimeoutConfig.cluster.nodes.other = { proxy: { backend: 'peer-federation', models: [] } }; +const sharedTimeoutUpdate = updateFederatedBackendTimeout(federationTimeoutConfig, { + nodeId: 'peer', + node: federationTimeoutConfig.cluster.nodes.peer, + timeoutMs: 7_200_000 +}); +assert.deepEqual(sharedTimeoutUpdate, { + backendId: 'peer-federation', + updated: false, + sharedNodeIds: ['other'] +}); +assert.equal(federationTimeoutConfig.backends['peer-federation'].timeoutMs, 3_600_000); + assert.deepEqual( runtimePlacement( { diff --git a/test/init-backend-timeout.test.mjs b/test/init-backend-timeout.test.mjs new file mode 100644 index 0000000..3ca5564 --- /dev/null +++ b/test/init-backend-timeout.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { deriveUserConfig } from '../src/init.mjs'; + +function recipe() { + return { + schemaVersion: 1, + id: 'synthetic-qwen-timeout', + version: 1, + name: 'Synthetic Qwen timeout', + backend: { id: 'mlx-lm' }, + models: [ + { + role: 'default', + model: 'synthetic/qwen', + gatewayModel: 'synthetic/qwen', + runtime: 'qwen-runtime', + capabilities: ['chat'], + settings: { + timeoutMs: 3_600_000, + contextWindow: 131_072 + } + } + ] + }; +} + +function config({ unrelatedModel = false, unrelatedRuntime = false } = {}) { + return { + server: { host: '127.0.0.1', port: 8100 }, + providers: { 'local-llm': { baseUrl: 'http://127.0.0.1:8100/v1' } }, + defaults: { chatModel: 'synthetic/qwen' }, + backends: { + 'managed-qwen': { + type: 'openai', + baseUrl: 'http://127.0.0.1:8235/v1', + apiKey: 'preserve-me', + timeoutMs: 1_800_000, + headers: { 'x-preserve': 'yes' } + } + }, + runtimes: { + 'qwen-runtime': { + enabled: true, + port: 8235, + recipe: { id: 'synthetic-qwen-timeout', version: 1 }, + backend: 'managed-qwen' + }, + ...(unrelatedRuntime + ? { + 'other-runtime': { + enabled: true, + port: 8236, + recipe: { id: 'other-recipe', version: 1 }, + backend: 'managed-qwen' + } + } + : {}) + }, + models: [ + { + id: 'synthetic/qwen', + name: 'Synthetic Qwen', + backend: 'managed-qwen', + runtime: 'qwen-runtime', + advertise: true + }, + ...(unrelatedModel + ? [ + { + id: 'other/model', + name: 'Other model', + backend: 'managed-qwen', + runtime: 'other-runtime', + advertise: true + } + ] + : []) + ], + aliases: {}, + clientCatalog: { modelOrder: ['synthetic/qwen'] } + }; +} + +function derive(input) { + return deriveUserConfig(input, recipe(), { + modelRoot: '/models', + sessionCacheRoot: '/session-cache' + }); +} + +test('refreshes an existing recipe-owned backend timeout without replacing its contract', () => { + const before = config(); + const derived = derive(before); + + assert.equal(derived.backends['managed-qwen'].timeoutMs, 3_600_000); + assert.equal(derived.backends['managed-qwen'].baseUrl, before.backends['managed-qwen'].baseUrl); + assert.equal(derived.backends['managed-qwen'].apiKey, before.backends['managed-qwen'].apiKey); + assert.deepEqual(derived.backends['managed-qwen'].headers, before.backends['managed-qwen'].headers); +}); + +test('leaves a backend timeout unchanged when another model or runtime shares it', () => { + for (const options of [{ unrelatedModel: true }, { unrelatedRuntime: true }]) { + const derived = derive(config(options)); + assert.equal( + derived.backends['managed-qwen'].timeoutMs, + 1_800_000, + `shared backend must remain unchanged for ${JSON.stringify(options)}` + ); + } +}); + +test('does not invent a timeout when the recipe omits timeoutMs', () => { + const noTimeoutRecipe = recipe(); + delete noTimeoutRecipe.models[0].settings.timeoutMs; + const derived = deriveUserConfig(config(), noTimeoutRecipe, { + modelRoot: '/models', + sessionCacheRoot: '/session-cache' + }); + assert.equal(derived.backends['managed-qwen'].timeoutMs, 1_800_000); +}); diff --git a/test/node-onboarding.test.mjs b/test/node-onboarding.test.mjs index b026ad7..0414a09 100644 --- a/test/node-onboarding.test.mjs +++ b/test/node-onboarding.test.mjs @@ -4,7 +4,7 @@ import { Readable } from 'node:stream'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { addAuthenticatedNode, readBoundedStdinCredential } from '../src/node-onboarding.mjs'; +import { addAuthenticatedNode, parsePositiveTimeoutMs, readBoundedStdinCredential } from '../src/node-onboarding.mjs'; const key = 'fixture-node-secret'; async function fixture(t) { @@ -96,3 +96,118 @@ test('environment credential mode uses the same identity, apply, and privacy gua assert.equal(peer.apiKey, undefined); assert.equal(JSON.stringify(result).includes(key), false); }); + +test('timeout-ms persists on the node and updates only its dedicated backend', async (t) => { + const f = await fixture(t); + const raw = JSON.parse(f.bytes); + raw.cluster.nodes.peer = { + name: 'Existing peer', + endpoint: 'http://old-peer.test:8100', + backendHost: 'old-peer.test', + labels: { custom: 'preserve' }, + resources: { memoryGb: 96 }, + proxy: { + backend: 'peer-federation', + namespace: 'old-namespace', + timeoutMs: 1_800_000, + models: [] + } + }; + raw.backends['peer-federation'] = { + type: 'openai', + baseUrl: 'http://old-peer.test:8100/v1', + apiKeyEnv: 'OLD_PEER_KEY', + headers: { 'x-fixture': 'preserve' }, + timeoutMs: 1_800_000 + }; + await fs.writeFile(f.sourcePath, JSON.stringify(raw), { mode: 0o600 }); + const result = await addAuthenticatedNode(f.config, { + nodeId: 'peer', + endpoint: 'http://peer.test:8100', + apiKey: key, + apiKeyEnv: 'NEW_PEER_KEY', + timeoutMs: '3600000', + apply: true, + yes: true, + fetchFn: async () => snapshot() + }); + assert.equal(result.timeoutMs, 3_600_000); + assert.equal(result.backendId, 'peer-federation'); + assert.equal(result.backendTimeoutUpdated, true); + const current = JSON.parse(await fs.readFile(f.sourcePath, 'utf8')); + const peer = current.cluster.nodes.peer; + assert.equal(peer.endpoint, 'http://peer.test:8100'); + assert.equal(peer.backendHost, 'old-peer.test'); + assert.equal(peer.labels.custom, 'preserve'); + assert.equal(peer.resources.memoryGb, 96); + assert.equal(peer.apiKeyEnv, 'NEW_PEER_KEY'); + assert.equal(peer.apiKey, undefined); + assert.equal(peer.proxy.backend, 'peer-federation'); + assert.equal(peer.proxy.timeoutMs, 3_600_000); + assert.equal(current.backends['peer-federation'].baseUrl, 'http://old-peer.test:8100/v1'); + assert.equal(current.backends['peer-federation'].apiKeyEnv, 'OLD_PEER_KEY'); + assert.deepEqual(current.backends['peer-federation'].headers, { 'x-fixture': 'preserve' }); + assert.equal(current.backends['peer-federation'].timeoutMs, 3_600_000); +}); + +test('omitting timeout-ms preserves an existing node and backend timeout', async (t) => { + const f = await fixture(t); + const raw = JSON.parse(f.bytes); + raw.cluster.nodes.peer = { + proxy: { backend: 'peer-federation', timeoutMs: 3_600_000, models: [] } + }; + raw.backends['peer-federation'] = { + type: 'openai', + baseUrl: 'http://peer.test:8100/v1', + timeoutMs: 3_600_000 + }; + await fs.writeFile(f.sourcePath, JSON.stringify(raw), { mode: 0o600 }); + await addAuthenticatedNode(f.config, { + nodeId: 'peer', + endpoint: 'http://peer.test:8100', + apiKey: key, + apply: true, + yes: true, + fetchFn: async () => snapshot() + }); + const current = JSON.parse(await fs.readFile(f.sourcePath, 'utf8')); + assert.equal(current.cluster.nodes.peer.proxy.timeoutMs, 3_600_000); + assert.equal(current.backends['peer-federation'].timeoutMs, 3_600_000); +}); + +test('timeout-ms refuses a backend shared by another federation node', async (t) => { + const f = await fixture(t); + const raw = JSON.parse(f.bytes); + raw.cluster.nodes.peer = { proxy: { backend: 'shared-federation', models: [] } }; + raw.cluster.nodes.other = { proxy: { backend: 'shared-federation', models: [] } }; + raw.backends['shared-federation'] = { + type: 'openai', + baseUrl: 'http://shared.test:8100/v1', + timeoutMs: 1_800_000 + }; + await fs.writeFile(f.sourcePath, JSON.stringify(raw), { mode: 0o600 }); + await assert.rejects( + addAuthenticatedNode(f.config, { + nodeId: 'peer', + endpoint: 'http://peer.test:8100', + apiKey: key, + timeoutMs: 3_600_000, + apply: true, + yes: true, + fetchFn: async () => snapshot() + }), + /shared-federation.*other/ + ); + const current = JSON.parse(await fs.readFile(f.sourcePath, 'utf8')); + assert.equal(current.cluster.nodes.peer.proxy.timeoutMs, undefined); + assert.equal(current.backends['shared-federation'].timeoutMs, 1_800_000); +}); + +test('timeout-ms accepts only positive safe integers', () => { + assert.equal(parsePositiveTimeoutMs('3600000'), 3_600_000); + assert.equal(parsePositiveTimeoutMs(3_600_000), 3_600_000); + for (const value of [undefined, null, '', 0, -1, '0', '1.5', '1e3', Number.MAX_SAFE_INTEGER + 1]) { + if (value == null || value === '') assert.equal(parsePositiveTimeoutMs(value), undefined); + else assert.throws(() => parsePositiveTimeoutMs(value), /positive integer/); + } +}); diff --git a/test/recipes-root-cli.test.mjs b/test/recipes-root-cli.test.mjs new file mode 100644 index 0000000..bb77706 --- /dev/null +++ b/test/recipes-root-cli.test.mjs @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import { spawn } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { createInitPlan, deriveUserConfig } from '../src/init.mjs'; + +const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const cliPath = path.join(repoRoot, 'bin', 'lloom.mjs'); + +function runCli(args, { home } = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: repoRoot, + env: { + ...process.env, + ...(home ? { HOME: home } : {}) + } + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +function emptyConfig() { + return { + server: { host: '127.0.0.1', port: 8100 }, + providers: { 'local-llm': { baseUrl: 'http://127.0.0.1:8100/v1' } }, + defaults: {}, + backends: {}, + runtimes: {}, + models: [], + aliases: {}, + clientCatalog: { modelOrder: [] } + }; +} + +test('CLI recipe operations honor an explicit recipes root', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lloom-recipes-root-cli-')); + const recipesRoot = path.join(root, 'recipes'); + const home = path.join(root, 'home'); + const modelRoot = path.join(root, 'models'); + const configPath = path.join(root, 'config.json'); + const statePath = path.join(root, 'install-state.json'); + const recipeId = 'synthetic-cli-recipes-root'; + const recipe = { + schemaVersion: 1, + id: recipeId, + name: 'Synthetic CLI recipes root', + version: 1, + requirements: { platforms: [`${process.platform}-${process.arch}`] }, + backend: { id: 'synthetic-backend' }, + setup: { steps: [] }, + models: [ + { + role: 'default', + model: 'synthetic/model', + gatewayModel: 'synthetic/model', + runtime: 'synthetic-runtime', + capabilities: ['chat'], + settings: { + runtime: { + command: '${installRoot}/server', + args: ['--port', '${port}'] + } + } + } + ] + }; + const catalogPath = path.join(root, 'backend-catalog.json'); + const catalog = { + schemaVersion: 1, + backends: [ + { + id: 'synthetic-backend', + name: 'Synthetic backend', + kind: 'openai-compatible-server', + platforms: [`${process.platform}-${process.arch}`], + commands: [], + server: { protocol: 'openai', healthPath: '/health' } + } + ] + }; + + try { + await fs.mkdir(recipesRoot, { recursive: true }); + await fs.writeFile(path.join(recipesRoot, `${recipeId}.json`), `${JSON.stringify(recipe)}\n`); + await fs.writeFile(catalogPath, `${JSON.stringify(catalog)}\n`); + + const init = await runCli( + [ + 'init', + '--recipe', + recipeId, + '--recipes-root', + recipesRoot, + '--backend-catalog', + catalogPath, + '--backend-port-range', + '8230-8299', + '--home', + home, + '--config-out', + configPath, + '--model-root', + modelRoot + ], + { home } + ); + assert.equal(init.code, 0, init.stderr); + const initPlan = JSON.parse(init.stdout); + assert.equal(initPlan.selectedRecipe.id, recipeId); + assert.deepEqual(initPlan.ports.backends.range, { start: 8230, end: 8299 }); + assert(initPlan.next.apply.includes(`--recipes-root '${recipesRoot}'`)); + assert(initPlan.next.apply.includes(`--backend-catalog '${catalogPath}'`)); + assert(initPlan.next.bootstrap.includes(`--recipes-root '${recipesRoot}'`)); + assert(initPlan.next.bootstrap.includes(`--backend-catalog '${catalogPath}'`)); + + const additiveConfig = emptyConfig(); + additiveConfig.defaults = { chatModel: 'existing-chat' }; + additiveConfig.models = [{ id: 'existing-chat', kind: 'chat', advertise: true }]; + additiveConfig.clientCatalog = { modelOrder: ['existing-chat'] }; + const additivePlan = await createInitPlan(additiveConfig, { + recipeId, + recipesRoot, + backendCatalogPath: catalogPath, + configPath, + modelRoot, + backendPortRange: '8230-8299', + home, + additive: true + }); + assert(additivePlan.next.review.includes('--additive')); + assert(additivePlan.next.apply.includes('--additive')); + assert(additivePlan.next.integrate.includes('--additive')); + assert.equal(additivePlan.config.defaults.chatModel, 'existing-chat'); + assert.deepEqual(additivePlan.config.clientCatalog.modelOrder.slice(0, 1), ['existing-chat']); + assert(additivePlan.config.clientCatalog.modelOrder.includes('synthetic/model')); + assert(additivePlan.config.models.some((model) => model.id === 'existing-chat')); + + const plan = await runCli(['plan', recipeId, '--recipes-root', recipesRoot, '--backend-catalog', catalogPath], { + home + }); + assert.equal(plan.code, 0, plan.stderr); + assert.equal(JSON.parse(plan.stdout).id, recipeId); + + const install = await runCli( + ['install', recipeId, '--recipes-root', recipesRoot, '--model-root', modelRoot, '--state', statePath], + { home } + ); + assert.equal(install.code, 0, install.stderr); + assert.equal(JSON.parse(install.stdout).plan.id, recipeId); + + await fs.writeFile(configPath, `${JSON.stringify(emptyConfig())}\n`); + const bootstrap = await runCli( + [ + 'bootstrap', + '--config', + configPath, + '--recipe', + recipeId, + '--recipes-root', + recipesRoot, + '--backend-catalog', + catalogPath, + '--model-root', + modelRoot, + '--state', + statePath + ], + { home } + ); + assert.equal(bootstrap.code, 0, bootstrap.stderr); + assert.equal(JSON.parse(bootstrap.stdout).selectedRecipe.id, recipeId); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('ordinary recipe runtimes receive backend template variables', () => { + const config = deriveUserConfig( + emptyConfig(), + { + schemaVersion: 1, + id: 'synthetic-explicit-runtime', + name: 'Synthetic explicit runtime', + backend: { id: 'synthetic-backend' }, + models: [ + { + role: 'default', + model: 'synthetic/model', + gatewayModel: 'synthetic/model', + runtime: 'synthetic-runtime', + capabilities: ['chat'], + settings: { + runtime: { + command: '${installRoot}/server', + args: ['--shim', '${shimDir}'] + } + } + } + ] + }, + { + modelRoot: '/models', + backendVariables: { + installRoot: '/isolated/install', + shimDir: '/isolated/bin' + } + } + ); + assert.equal(config.runtimes['synthetic-runtime'].command, '/isolated/install/server'); + assert.deepEqual(config.runtimes['synthetic-runtime'].args, ['--shim', '/isolated/bin']); +}); + +test('explicit backend port range is used before retargeting an additive runtime', () => { + const config = emptyConfig(); + config.ports = { backends: { start: 8234, end: 8234 } }; + config.defaults = { chatModel: 'existing-chat' }; + config.models = [{ id: 'existing-chat', kind: 'chat', runtime: 'existing-runtime', advertise: true }]; + config.backends = { + existing: { type: 'openai', baseUrl: 'http://127.0.0.1:8234/v1', apiKey: 'sk-local-llm' } + }; + config.runtimes = { + 'existing-runtime': { enabled: true, port: 8234, keepWarm: true, memoryGb: 12 } + }; + config.clientCatalog = { modelOrder: ['existing-chat'] }; + + const derived = deriveUserConfig( + config, + { + schemaVersion: 1, + id: 'synthetic-port-override', + name: 'Synthetic port override', + backend: { id: 'synthetic-backend' }, + models: [ + { + role: 'default', + model: 'synthetic/model', + gatewayModel: 'synthetic/model', + runtime: 'synthetic-runtime', + capabilities: ['chat'], + settings: { + runtime: { command: '${installRoot}/server', args: ['--port', '${port}'] } + } + } + ] + }, + { + modelRoot: '/models', + backendPortRange: '8235-8235', + additive: true, + backendVariables: { installRoot: '/isolated/install' } + } + ); + + assert.equal(derived.runtimes['synthetic-runtime'].port, 8235); + assert.deepEqual(derived.ports.backends.range, { start: 8235, end: 8235 }); + assert.equal(derived.runtimes['existing-runtime'].port, 8234); + assert.equal(derived.runtimes['existing-runtime'].keepWarm, true); + assert.equal(derived.runtimes['existing-runtime'].memoryGb, 12); + assert.equal(derived.defaults.chatModel, 'existing-chat'); + assert.equal(derived.backends['synthetic-backend-synthetic-model'].baseUrl, 'http://127.0.0.1:8235/v1'); +});