From 016b1fe88edc08fa32ece394113ab398ecb59373 Mon Sep 17 00:00:00 2001 From: Jason McCartney Date: Sun, 27 Sep 2026 12:01:45 -0700 Subject: [PATCH 1/2] Apply Prettier 3.9.8 formatting so CI's format check passes Co-Authored-By: Claude Opus 5.5 --- bin/lloom.mjs | 4 +- src/cluster.mjs | 9 +- src/config-profiles.mjs | 9 +- src/host-memory.mjs | 6 +- test/config-profiles.test.mjs | 17 ++- test/openrouter-provider.test.mjs | 178 +++++++++++++++--------------- 6 files changed, 125 insertions(+), 98 deletions(-) diff --git a/bin/lloom.mjs b/bin/lloom.mjs index 556a565..443e4cb 100755 --- a/bin/lloom.mjs +++ b/bin/lloom.mjs @@ -2444,9 +2444,7 @@ async function main() { `/gateway/fleet/profiles/${encodeURIComponent(name)}${isApply ? '?apply=1' : ''}`, { method: 'POST', - body: isApply - ? { yes: true } - : { yes: true, description: plan.description, overwrite: plan.overwrite }, + body: isApply ? { yes: true } : { yes: true, description: plan.description, overwrite: plan.overwrite }, timeoutMs: 60000 } ); diff --git a/src/cluster.mjs b/src/cluster.mjs index c18598a..4090dcc 100644 --- a/src/cluster.mjs +++ b/src/cluster.mjs @@ -888,7 +888,14 @@ export function validateClusterConfig(config, env = process.env) { export class ClusterCoordinator { constructor( config, - { env = process.env, fetchImpl = undiciFetch, logger = console, telemetry = null, profile = null, models = null } = {} + { + env = process.env, + fetchImpl = undiciFetch, + logger = console, + telemetry = null, + profile = null, + models = null + } = {} ) { this.config = config; this.env = env; diff --git a/src/config-profiles.mjs b/src/config-profiles.mjs index 16b052c..6a4e1fd 100644 --- a/src/config-profiles.mjs +++ b/src/config-profiles.mjs @@ -49,8 +49,7 @@ export function resolveRouteTarget(alias, target) { const id = target.trim(); const known = (candidate) => candidate === id || (object(alias.members)?.includes ?? (() => false)).call(alias.members, id); - if (!known(id) && !Array.isArray(alias.members)) - throw fail(`Alias has no route profile or member named ${id}.`); + if (!known(id) && !Array.isArray(alias.members)) throw fail(`Alias has no route profile or member named ${id}.`); return { activeRoute: null, members: [id], optionalMembers: [] }; } @@ -72,13 +71,15 @@ export function normalizeProfileDocument(raw, name) { for (const [runtimeId, policy] of Object.entries(object(doc.residency) ?? {})) { if (typeof runtimeId !== 'string' || !runtimeId.trim() || runtimeId.length > 200) throw fail(`Profile ${name} has an invalid runtime id.`); - if (!RESIDENCY.has(policy)) throw fail(`Profile ${name}: residency for ${runtimeId} must be always, preferred, or auto.`); + if (!RESIDENCY.has(policy)) + throw fail(`Profile ${name}: residency for ${runtimeId} must be always, preferred, or auto.`); residency[runtimeId.trim()] = policy; } const defaults = object(doc.defaults); if (defaults) { for (const [key, value] of Object.entries(defaults)) { - if (typeof value !== 'string' || value.length > 500) throw fail(`Profile ${name}: defaults.${key} must be a short string.`); + if (typeof value !== 'string' || value.length > 500) + throw fail(`Profile ${name}: defaults.${key} must be a short string.`); } } return { diff --git a/src/host-memory.mjs b/src/host-memory.mjs index a5486fd..8481cff 100644 --- a/src/host-memory.mjs +++ b/src/host-memory.mjs @@ -45,7 +45,11 @@ export function parseMacMemoryPressure(text, totalBytes) { // the "System-wide memory free percentage" is an opaque kernel estimate that // can understate true availability while the file cache holds pages. if ([free, inactive, speculative, purgeable].every(Number.isFinite)) { - return memorySnapshot(totalBytes, Math.min(totalBytes, (free + inactive + speculative + purgeable) * pageSize), 'macos-memory-pages'); + return memorySnapshot( + totalBytes, + Math.min(totalBytes, (free + inactive + speculative + purgeable) * pageSize), + 'macos-memory-pages' + ); } const match = text_.match(/System-wide memory free percentage:\s*([\d.]+)%/i); const percentage = Number(match?.[1]); diff --git a/test/config-profiles.test.mjs b/test/config-profiles.test.mjs index be897f1..682aa41 100644 --- a/test/config-profiles.test.mjs +++ b/test/config-profiles.test.mjs @@ -77,7 +77,11 @@ try { 'cloud' ); const localPlan = planProfileChanges(config, localProfile); - assert.equal(localPlan.unchanged.length, 2, 'omp route + residency are no-ops; simple pins members (members rewrite)'); + assert.equal( + localPlan.unchanged.length, + 2, + 'omp route + residency are no-ops; simple pins members (members rewrite)' + ); const cloudPlan = planProfileChanges(config, cloudProfile); assert.equal(cloudPlan.routes.length, 1); assert.deepEqual(cloudPlan.routes[0], { @@ -90,7 +94,10 @@ try { }); assert.deepEqual(cloudPlan.residency, [{ id: 'local-model', from: 'always', to: 'auto' }]); assert.deepEqual(cloudPlan.defaults, [{ id: 'chatModel', from: 'local-model', to: 'cloud-model' }]); - assert.throws(() => planProfileChanges(config, normalizeProfileDocument({ routes: { ghost: 'cloud' } }, 'x')), /no alias ghost/); + assert.throws( + () => planProfileChanges(config, normalizeProfileDocument({ routes: { ghost: 'cloud' } }, 'x')), + /no alias ghost/ + ); assert.throws( () => planProfileChanges(config, normalizeProfileDocument({ residency: { ghost: 'auto' } }, 'x')), /no runtime ghost/ @@ -127,7 +134,11 @@ try { const applied = await controller.apply('cloud', { yes: true }); assert.equal(applied.profile, 'cloud'); assert.equal(applied.routes.length, 1); - assert.equal(applied.unchanged.length, 0, 'second plan sees no changes after apply? no: apply recomputes against raw'); + assert.equal( + applied.unchanged.length, + 0, + 'second plan sees no changes after apply? no: apply recomputes against raw' + ); const onDisk = JSON.parse(await fs.readFile(configPath, 'utf8')); assert.equal(onDisk.fleet.activeProfile, 'cloud'); assert.deepEqual(onDisk.aliases.omp.members, ['cloud-model']); diff --git a/test/openrouter-provider.test.mjs b/test/openrouter-provider.test.mjs index 25388e6..90fe585 100644 --- a/test/openrouter-provider.test.mjs +++ b/test/openrouter-provider.test.mjs @@ -256,20 +256,21 @@ async function testGatewayChatBuffered() { await withMockedDispatcher( async () => startGateway(openRouterBackend({ openrouterProvider: { only: ['z-ai'], allow_fallbacks: false } })), async (mockAgent) => { - intercept(mockAgent, { payload: openAiChatPayload, onBody: (body) => seen.push(body) }); - const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - model: 'z-ai/glm-5.2', - messages: [{ role: 'user', content: 'hi' }], - provider: { only: ['openai'], allow_fallbacks: true, order: ['x'] } - }) - }); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(json.choices[0].message.content, 'ok'); - }); + intercept(mockAgent, { payload: openAiChatPayload, onBody: (body) => seen.push(body) }); + const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'z-ai/glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + provider: { only: ['openai'], allow_fallbacks: true, order: ['x'] } + }) + }); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.choices[0].message.content, 'ok'); + } + ); assert.equal(seen.length, 1, 'expected exactly one upstream chat call'); const outbound = JSON.parse(seen[0]); // Caller override attempt is defeated; other caller provider fields survive. @@ -287,24 +288,25 @@ async function testGatewayChatStream() { await withMockedDispatcher( async () => startGateway(openRouterBackend({ openrouterProvider: { only: ['z-ai'] } })), async (mockAgent) => { - intercept(mockAgent, { - contentType: 'text/event-stream', - payload: openAiStreamPayload, - onBody: (body) => seen.push(body) - }); - const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - model: 'z-ai/glm-5.2', - messages: [{ role: 'user', content: 'hi' }], - stream: true - }) - }); - assert.equal(res.status, 200); - const text = await res.text(); - assert.match(text, /data: \[DONE\]/); - }); + intercept(mockAgent, { + contentType: 'text/event-stream', + payload: openAiStreamPayload, + onBody: (body) => seen.push(body) + }); + const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'z-ai/glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + stream: true + }) + }); + assert.equal(res.status, 200); + const text = await res.text(); + assert.match(text, /data: \[DONE\]/); + } + ); assert.equal(seen.length, 1); const outbound = JSON.parse(seen[0]); assert.deepEqual(outbound.provider, { only: ['z-ai'], allow_fallbacks: false }); @@ -320,20 +322,21 @@ async function testGatewayResponsesBridge(stream = false) { await withMockedDispatcher( async () => startGateway(openRouterBackend({ openrouterProvider: { only: ['z-ai'] } })), async (mockAgent) => { - intercept(mockAgent, { - payload: stream ? openAiStreamPayload : openAiChatPayload, - contentType: stream ? 'text/event-stream' : 'application/json', - onBody: (body) => seen.push(body) - }); - const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/responses`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ stream, model: 'z-ai/glm-5.2', input: 'hi', provider: { only: ['openai'] } }) - }); - assert.equal(res.status, 200); - if (stream) assert.match(await res.text(), /response.completed/); - else assert.equal((await res.json()).object, 'response'); - }); + intercept(mockAgent, { + payload: stream ? openAiStreamPayload : openAiChatPayload, + contentType: stream ? 'text/event-stream' : 'application/json', + onBody: (body) => seen.push(body) + }); + const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/responses`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ stream, model: 'z-ai/glm-5.2', input: 'hi', provider: { only: ['openai'] } }) + }); + assert.equal(res.status, 200); + if (stream) assert.match(await res.text(), /response.completed/); + else assert.equal((await res.json()).object, 'response'); + } + ); assert.equal(seen.length, 1); const outbound = JSON.parse(seen[0]); assert.deepEqual(outbound.provider, { only: ['z-ai'], allow_fallbacks: false }); @@ -348,25 +351,26 @@ async function testGatewayAnthropicBridge(stream = false) { await withMockedDispatcher( async () => startGateway(openRouterBackend({ openrouterProvider: { only: ['z-ai'], allow_fallbacks: true } })), async (mockAgent) => { - intercept(mockAgent, { - payload: stream ? openAiStreamPayload : openAiChatPayload, - contentType: stream ? 'text/event-stream' : 'application/json', - onBody: (body) => seen.push(body) - }); - const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/messages`, { - method: 'POST', - headers: { 'content-type': 'application/json', 'anthropic-version': '2023-06-01' }, - body: JSON.stringify({ - stream, - model: 'z-ai/glm-5.2', - max_tokens: 32, - messages: [{ role: 'user', content: 'hi' }] - }) - }); - assert.equal(res.status, 200); - if (stream) assert.match(await res.text(), /message_stop/); - else assert.equal((await res.json()).type, 'message'); - }); + intercept(mockAgent, { + payload: stream ? openAiStreamPayload : openAiChatPayload, + contentType: stream ? 'text/event-stream' : 'application/json', + onBody: (body) => seen.push(body) + }); + const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'anthropic-version': '2023-06-01' }, + body: JSON.stringify({ + stream, + model: 'z-ai/glm-5.2', + max_tokens: 32, + messages: [{ role: 'user', content: 'hi' }] + }) + }); + assert.equal(res.status, 200); + if (stream) assert.match(await res.text(), /message_stop/); + else assert.equal((await res.json()).type, 'message'); + } + ); assert.equal(seen.length, 1); const outbound = JSON.parse(seen[0]); assert.deepEqual(outbound.provider, { only: ['z-ai'], allow_fallbacks: true }); @@ -381,14 +385,15 @@ async function testGatewayMalformedPolicyFailsClosed() { await withMockedDispatcher( async () => startGateway(openRouterBackend({ openrouterProvider: { only: [] } })), async () => { - const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ model: 'z-ai/glm-5.2', messages: [{ role: 'user', content: 'hi' }] }) - }); - assert.notEqual(res.status, 200); - await res.text(); - }); + const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'z-ai/glm-5.2', messages: [{ role: 'user', content: 'hi' }] }) + }); + assert.notEqual(res.status, 200); + await res.text(); + } + ); } finally { await stopGateway(); } @@ -400,19 +405,20 @@ async function testGatewayNoPolicyUntouched() { await withMockedDispatcher( async () => startGateway({ id: 'openrouter-lane', type: 'openai', baseUrl: 'https://openrouter.ai/api/v1' }), async (mockAgent) => { - intercept(mockAgent, { payload: openAiChatPayload, onBody: (body) => seen.push(body) }); - const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - model: 'z-ai/glm-5.2', - messages: [{ role: 'user', content: 'hi' }], - provider: { only: ['openai'], allow_fallbacks: true } - }) - }); - assert.equal(res.status, 200); - await res.text(); - }); + intercept(mockAgent, { payload: openAiChatPayload, onBody: (body) => seen.push(body) }); + const res = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'z-ai/glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + provider: { only: ['openai'], allow_fallbacks: true } + }) + }); + assert.equal(res.status, 200); + await res.text(); + } + ); const outbound = JSON.parse(seen[0]); assert.deepEqual(outbound.provider, { only: ['openai'], allow_fallbacks: true }); } finally { From 10afa775a0bfac82bc935ec64c521c864e4ecb9c Mon Sep 17 00:00:00 2001 From: Jason McCartney Date: Sun, 27 Sep 2026 13:13:52 -0700 Subject: [PATCH 2/2] Require Node.js 22.19, the floor undici 8 needs undici 8 declares node >=22.19.0 and crashes on import under Node 20, so CI's Node 20 job fails on main. Raise the engine range, test on 22 and 24, and update the contributor guide. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 ++ CONTRIBUTING.md | 2 +- package-lock.json | 2 +- package.json | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f1d5c9..f564317 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - node: [20, 22] + node: [22, 24] steps: - name: Check out repository diff --git a/CHANGELOG.md b/CHANGELOG.md index 78934a9..36ed5cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ All notable changes to LLooM will be documented in this file. The format follows ### Changed +- LLooM now requires Node.js 22.19 or newer, the minimum for its undici 8 HTTP client. It already failed to start on Node 20 after that upgrade; the engine range and CI matrix now say so. + - The action view frames only models serving traffic and their live requests. Active cards cluster near the loom; idle models and machine racks no longer pull the camera outward. Manual zoom holds independently of the automatic fit until Reset view restores it. - Federated nodes now retain sovereign lifecycle control over ordinary local runtimes, while tensor-parallel members explicitly delegate lifecycle authority to their leader and remain non-callable on workers. - Runtime residency now uses `keepWarm` as the single hard pin, keeps distributed-model pins on the logical runtime, and routes ready alias alternatives without eviction or capacity queuing; embeddings remain non-evicting even when requested by exact model ID. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6d2768..0fb9efb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for helping improve LLooM. The project welcomes focused fixes, new bac Requirements: -- Node.js 20 or newer +- Node.js 22.19 or newer - npm - Python 3 for syntax-checking the optional MLX Audio and MTPLX patch helpers - macOS, Linux, or another platform capable of running the Node.js test suite diff --git a/package-lock.json b/package-lock.json index 9bba72a..3e7709c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "prettier": "^3.9.8" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" } }, "node_modules/@cacheable/memory": { diff --git a/package.json b/package.json index ea65903..4b5a53e 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "test:memory-safety": "node --test test/runtime-memory-safety.test.mjs" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" }, "license": "MIT", "devDependencies": {