mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(docker): bundle LLMLingua optional dependencies (#9185)
Validated in local merge-train T4 (HouMinXi+Zartharas+Andrian+artickc)
This commit is contained in:
@@ -77,7 +77,7 @@ RUN test -f package-lock.json \
|
||||
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
|
||||
# broken image.
|
||||
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& (cd node_modules/better-sqlite3 \
|
||||
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()" \
|
||||
@@ -119,7 +119,9 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data && npm run build
|
||||
mkdir -p /app/data \
|
||||
&& npm run build \
|
||||
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
colocateLlmlinguaOptionals,
|
||||
SEED_PACKAGES,
|
||||
} from "./colocateOptionals.mjs";
|
||||
|
||||
/**
|
||||
* Check whether a path exists (async).
|
||||
@@ -736,6 +740,19 @@ export function assembleStandalone({
|
||||
// 6. Optionally copy native assets + extra modules (synchronous)
|
||||
if (copyNatives) {
|
||||
copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
|
||||
|
||||
// #9166: dynamically imported LLMLingua packages are not reliably traced
|
||||
// into the standalone bundle. Copy their complete dependency closure from
|
||||
// the installed root tree without overwriting packages already traced by
|
||||
// Next.js. Include transformers here so its ONNX runtime closure is also
|
||||
// guaranteed in Docker/standalone builds.
|
||||
colocateLlmlinguaOptionals({
|
||||
rootDir: projectRoot,
|
||||
targetNodeModulesDir: path.join(resolvedOutDir, "node_modules"),
|
||||
seeds: [...SEED_PACKAGES, "@huggingface/transformers"],
|
||||
log: (message) =>
|
||||
console.log(`[assembleStandalone] ${message.trim()}`),
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Optionally dereference Turbopack hashed-module symlinks so the bundle is
|
||||
|
||||
@@ -97,47 +97,81 @@ export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Co-locate the SLM optional closure from `<rootDir>/node_modules` into
|
||||
* `<rootDir>/dist/node_modules`. No-op when the standalone `dist` bundle or the optional seeds are
|
||||
* absent, and idempotent once co-located. Never throws.
|
||||
* Co-locate the SLM optional dependency closure from `<rootDir>/node_modules`
|
||||
* into a standalone bundle's `node_modules`.
|
||||
*
|
||||
* @param {{ rootDir: string, log?: (message: string) => void }} opts
|
||||
* The default destination remains `<rootDir>/dist/node_modules` for the npm
|
||||
* postinstall path. Standalone builders, including Docker, may provide
|
||||
* `targetNodeModulesDir`.
|
||||
*
|
||||
* Packages already present in the destination are never overwritten. This
|
||||
* preserves the standalone bundle's pinned dependency instances while filling
|
||||
* dynamically imported packages that Next.js did not trace.
|
||||
*
|
||||
* @param {{
|
||||
* rootDir: string,
|
||||
* targetNodeModulesDir?: string,
|
||||
* seeds?: string[],
|
||||
* log?: (message: string) => void
|
||||
* }} opts
|
||||
* @returns {{ skipped: true, reason: string }
|
||||
* | { skipped: false, copied: number, closure: number }}
|
||||
*/
|
||||
export function colocateLlmlinguaOptionals({ rootDir, log = () => {} }) {
|
||||
export function colocateLlmlinguaOptionals({
|
||||
rootDir,
|
||||
targetNodeModulesDir,
|
||||
seeds = SEED_PACKAGES,
|
||||
log = () => {},
|
||||
}) {
|
||||
const rootNm = join(rootDir, "node_modules");
|
||||
const distNm = join(rootDir, "dist", "node_modules");
|
||||
const targetNm = targetNodeModulesDir ?? join(rootDir, "dist", "node_modules");
|
||||
|
||||
if (!existsSync(distNm)) {
|
||||
return { skipped: true, reason: "no standalone dist/node_modules" };
|
||||
if (!existsSync(targetNm)) {
|
||||
return {
|
||||
skipped: true,
|
||||
reason: targetNodeModulesDir
|
||||
? "no target node_modules"
|
||||
: "no standalone dist/node_modules",
|
||||
};
|
||||
}
|
||||
// Gate: only run when the optional stack was actually installed (`npm install --include=optional`).
|
||||
if (!SEED_PACKAGES.every((seed) => existsSync(join(rootNm, seed)))) {
|
||||
|
||||
// Only run when every requested closure root was installed.
|
||||
if (!seeds.every((seed) => existsSync(join(rootNm, seed)))) {
|
||||
return { skipped: true, reason: "SLM optionals not installed at root" };
|
||||
}
|
||||
// Idempotent: the entry package is already co-located → nothing to do.
|
||||
if (existsSync(join(distNm, "@atjsh", "llmlingua-2"))) {
|
||||
|
||||
const closure = computeDependencyClosure(rootNm, seeds);
|
||||
|
||||
// Check the complete closure rather than only the entry package. A partially
|
||||
// populated bundle must still receive any missing transitive dependencies.
|
||||
if (
|
||||
closure.length > 0 &&
|
||||
closure.every((name) => existsSync(join(targetNm, name)))
|
||||
) {
|
||||
return { skipped: true, reason: "already co-located" };
|
||||
}
|
||||
|
||||
const closure = computeDependencyClosure(rootNm);
|
||||
let copied = 0;
|
||||
|
||||
for (const name of closure) {
|
||||
const dest = join(distNm, name);
|
||||
if (existsSync(dest)) continue; // no-clobber: keep dist's pinned copy (transformers 3.5.2, …)
|
||||
const dest = join(targetNm, name);
|
||||
if (existsSync(dest)) continue;
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
cpSync(join(rootNm, name), dest, { recursive: true });
|
||||
copied++;
|
||||
} catch (err) {
|
||||
log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`);
|
||||
log(
|
||||
` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (copied > 0) {
|
||||
log(` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into dist/node_modules.\n`);
|
||||
log(
|
||||
` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into standalone node_modules.\n`
|
||||
);
|
||||
}
|
||||
|
||||
return { skipped: false, copied, closure: closure.length };
|
||||
|
||||
291
tests/unit/docker-llmlingua-optionals-9166.test.ts
Normal file
291
tests/unit/docker-llmlingua-optionals-9166.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { assembleStandalone } from "../../scripts/build/assembleStandalone.mjs";
|
||||
|
||||
const REQUIRED_RUNTIME_PACKAGES = [
|
||||
"@atjsh/llmlingua-2",
|
||||
"@huggingface/transformers",
|
||||
"@tensorflow/tfjs",
|
||||
"js-tiktoken",
|
||||
];
|
||||
|
||||
function mkPkg(
|
||||
nodeModulesDir: string,
|
||||
name: string,
|
||||
manifest: Record<string, unknown> = {},
|
||||
files: Record<string, string> = {}
|
||||
): void {
|
||||
const packageDir = join(nodeModulesDir, name);
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(packageDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name,
|
||||
version: "1.0.0",
|
||||
...manifest,
|
||||
})
|
||||
);
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const filePath = join(packageDir, relativePath);
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLlmlinguaRoot(
|
||||
rootDir: string,
|
||||
transformersVersion = "3.5.2"
|
||||
): void {
|
||||
const rootNm = join(rootDir, "node_modules");
|
||||
|
||||
mkPkg(
|
||||
rootNm,
|
||||
"@atjsh/llmlingua-2",
|
||||
{
|
||||
dependencies: {
|
||||
"es-toolkit": "^1.38.0",
|
||||
},
|
||||
peerDependencies: {
|
||||
"@huggingface/transformers": "*",
|
||||
"@tensorflow/tfjs": "*",
|
||||
"js-tiktoken": "*",
|
||||
},
|
||||
},
|
||||
{
|
||||
"dist/index.js": "export const llmlingua = true;\n",
|
||||
}
|
||||
);
|
||||
|
||||
mkPkg(rootNm, "es-toolkit");
|
||||
|
||||
mkPkg(rootNm, "@tensorflow/tfjs", {
|
||||
dependencies: {
|
||||
"@tensorflow/tfjs-core": "4.22.0",
|
||||
},
|
||||
});
|
||||
mkPkg(rootNm, "@tensorflow/tfjs-core", {
|
||||
dependencies: {
|
||||
long: "^5.0.0",
|
||||
},
|
||||
});
|
||||
mkPkg(rootNm, "long");
|
||||
|
||||
mkPkg(rootNm, "js-tiktoken", {
|
||||
dependencies: {
|
||||
"base64-js": "^1.5.1",
|
||||
},
|
||||
});
|
||||
mkPkg(rootNm, "base64-js");
|
||||
|
||||
mkPkg(rootNm, "@huggingface/transformers", {
|
||||
version: transformersVersion,
|
||||
dependencies: {
|
||||
"onnxruntime-node": "1.21.0",
|
||||
},
|
||||
});
|
||||
mkPkg(rootNm, "onnxruntime-node");
|
||||
}
|
||||
|
||||
function createStandalone(rootDir: string): {
|
||||
distDir: string;
|
||||
standaloneDir: string;
|
||||
} {
|
||||
const distDir = join(rootDir, ".build", "next");
|
||||
const standaloneDir = join(distDir, "standalone");
|
||||
|
||||
mkdirSync(join(standaloneDir, "node_modules"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(standaloneDir, "package.json"),
|
||||
JSON.stringify({ name: "standalone-test" })
|
||||
);
|
||||
|
||||
return { distDir, standaloneDir };
|
||||
}
|
||||
|
||||
test("#9166 standalone assembly includes the complete LLMLingua runtime closure", () => {
|
||||
const root = mkdtempSync(
|
||||
join(tmpdir(), "omniroute-docker-llmlingua-9166-")
|
||||
);
|
||||
|
||||
try {
|
||||
buildLlmlinguaRoot(root);
|
||||
const { distDir, standaloneDir } = createStandalone(root);
|
||||
|
||||
assembleStandalone({
|
||||
distDir,
|
||||
outDir: standaloneDir,
|
||||
projectRoot: root,
|
||||
copyNatives: true,
|
||||
});
|
||||
|
||||
for (const packageName of [
|
||||
...REQUIRED_RUNTIME_PACKAGES,
|
||||
"es-toolkit",
|
||||
"@tensorflow/tfjs-core",
|
||||
"long",
|
||||
"base64-js",
|
||||
"onnxruntime-node",
|
||||
]) {
|
||||
assert.ok(
|
||||
existsSync(
|
||||
join(standaloneDir, "node_modules", packageName, "package.json")
|
||||
),
|
||||
`${packageName} must be present in the standalone runtime`
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
existsSync(
|
||||
join(
|
||||
standaloneDir,
|
||||
"node_modules",
|
||||
"@atjsh",
|
||||
"llmlingua-2",
|
||||
"dist",
|
||||
"index.js"
|
||||
)
|
||||
),
|
||||
"the complete LLMLingua package payload must be copied"
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("#9166 standalone assembly never overwrites an already pinned transformers instance", () => {
|
||||
const root = mkdtempSync(
|
||||
join(tmpdir(), "omniroute-docker-llmlingua-pinned-9166-")
|
||||
);
|
||||
|
||||
try {
|
||||
buildLlmlinguaRoot(root, "4.2.0");
|
||||
const { distDir, standaloneDir } = createStandalone(root);
|
||||
|
||||
mkPkg(
|
||||
join(standaloneDir, "node_modules"),
|
||||
"@huggingface/transformers",
|
||||
{
|
||||
version: "3.5.2",
|
||||
}
|
||||
);
|
||||
|
||||
assembleStandalone({
|
||||
distDir,
|
||||
outDir: standaloneDir,
|
||||
projectRoot: root,
|
||||
copyNatives: true,
|
||||
});
|
||||
|
||||
const targetManifest = JSON.parse(
|
||||
readFileSync(
|
||||
join(
|
||||
standaloneDir,
|
||||
"node_modules",
|
||||
"@huggingface",
|
||||
"transformers",
|
||||
"package.json"
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
targetManifest.version,
|
||||
"3.5.2",
|
||||
"standalone's pinned transformers version must not be overwritten"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
existsSync(
|
||||
join(
|
||||
standaloneDir,
|
||||
"node_modules",
|
||||
"onnxruntime-node",
|
||||
"package.json"
|
||||
)
|
||||
),
|
||||
"missing dependencies from the transformers closure must still be copied"
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("#9166 Docker explicitly installs and validates LLMLingua optionals", () => {
|
||||
const dockerfile = readFileSync(
|
||||
new URL("../../Dockerfile", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const builderStart = dockerfile.indexOf("FROM base AS builder");
|
||||
const runnerStart = dockerfile.indexOf("FROM base AS runner-base");
|
||||
|
||||
assert.ok(builderStart >= 0, "Docker builder stage must exist");
|
||||
assert.ok(runnerStart > builderStart, "Docker runner stage must follow builder");
|
||||
|
||||
const builder = dockerfile.slice(builderStart, runnerStart);
|
||||
|
||||
assert.match(
|
||||
builder,
|
||||
/npm ci\b[^\n]*--include=optional/,
|
||||
"Docker dependency installation must explicitly include optional dependencies"
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
builder,
|
||||
/npm ci\b[^\n]*--omit=optional/,
|
||||
"Docker must not omit optional dependencies"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
builder,
|
||||
/createRequire\(['"]\/app\/\.build\/next\/standalone\/package\.json['"]\)/,
|
||||
"Docker validation must resolve packages from the standalone package context"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
builder,
|
||||
/resolved\.startsWith\(standaloneRoot\)/,
|
||||
"Docker validation must reject packages resolved from the builder root"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
builder,
|
||||
/await import\(pathToFileURL\(resolved\)\.href\)/,
|
||||
"Docker validation must import each packaged LLMLingua dependency"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
builder.includes("require.resolve('onnxruntime-node')"),
|
||||
"Docker validation must resolve the native ONNX runtime"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
builder,
|
||||
/await import\(pathToFileURL\(onnxRuntime\)\.href\)/,
|
||||
"Docker validation must load the ONNX runtime and its native binding"
|
||||
);
|
||||
|
||||
for (const packageName of REQUIRED_RUNTIME_PACKAGES) {
|
||||
assert.ok(
|
||||
builder.includes(packageName),
|
||||
`Docker standalone validation must include ${packageName}`
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user