From 372e789eeb60214c3c576b4a7821b2f8115f3f33 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Thu, 30 Jul 2026 12:45:59 +0200 Subject: [PATCH 1/2] fix(embedding): load transformers via CJS so Intel Mac onnxruntime pin applies (#210) --- .github/workflows/embedding-backend.yml | 44 ++- .github/workflows/platform-smoke.yml | 5 + README.md | 6 +- scripts/verify-embedding-backend.mjs | 54 +++- scripts/verify-nested-onnxruntime-fixture.mjs | 266 ++++++++++++++++++ src/services/embedding.ts | 25 +- src/services/onnxruntime-resolve.ts | 105 ++++++- tests/onnxruntime-resolve.test.ts | 242 +++++++++++++++- 8 files changed, 699 insertions(+), 48 deletions(-) create mode 100644 scripts/verify-nested-onnxruntime-fixture.mjs diff --git a/.github/workflows/embedding-backend.yml b/.github/workflows/embedding-backend.yml index be5cdd89..d6675d7e 100644 --- a/.github/workflows/embedding-backend.yml +++ b/.github/workflows/embedding-backend.yml @@ -15,6 +15,7 @@ on: - "src/services/embedding.ts" - "src/services/onnxruntime-resolve.ts" - "scripts/verify-embedding-backend.mjs" + - "scripts/verify-nested-onnxruntime-fixture.mjs" - ".github/workflows/embedding-backend.yml" workflow_dispatch: @@ -24,9 +25,18 @@ jobs: strategy: fail-fast: false matrix: - # macos-15-intel = Intel (darwin/x64); macos-15 = Apple Silicon (darwin/arm64). - # Intel must keep resolving onnxruntime-node@1.22.0 (#184). - os: [ubuntu-latest, macos-15-intel, macos-15, windows-latest] + # Intel: macos-15-intel / macos-26-intel (darwin/x64). + # Apple Silicon: macos-15 / macos-26 (darwin/arm64). + # Intel must keep resolving onnxruntime-node@1.22.0 (#184 / #210). + os: + [ + ubuntu-latest, + macos-15-intel, + macos-26-intel, + macos-15, + macos-26, + windows-latest, + ] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 @@ -72,3 +82,31 @@ jobs: - name: Embedding smoke (Node) run: node scripts/verify-embedding-backend.mjs + + # Targeted #210 regression: OpenCode-shaped nested install on the oldest + # available standard Intel runner, using the reporter's Bun/Node versions. + nested-intel-regression: + name: macos-15-intel / nested OpenCode fixture + runs-on: macos-15-intel + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - uses: actions/setup-node@v5 + with: + node-version: "22" + package-manager-cache: false + + - name: Install and build package + run: | + bun install + bun run build + + - name: Nested fixture + embedding (Bun 1.3.14) + run: bun scripts/verify-nested-onnxruntime-fixture.mjs + + - name: Nested fixture + embedding (Node 22) + run: node scripts/verify-nested-onnxruntime-fixture.mjs diff --git a/.github/workflows/platform-smoke.yml b/.github/workflows/platform-smoke.yml index 9da0cde0..3b13fd25 100644 --- a/.github/workflows/platform-smoke.yml +++ b/.github/workflows/platform-smoke.yml @@ -9,6 +9,7 @@ on: - "src/**" - "scripts/native-deps-smoke.mjs" - "scripts/verify-libsql-vector.mjs" + - "scripts/verify-nested-onnxruntime-fixture.mjs" - "scripts/smoke-test.mjs" - "web/**" - ".github/workflows/platform-smoke.yml" @@ -21,11 +22,15 @@ jobs: strategy: fail-fast: false matrix: + # Intel: macos-15-intel / macos-26-intel (darwin/x64). + # Apple Silicon: macos-15 / macos-26 (darwin/arm64). os: - ubuntu-latest - windows-latest - macos-15-intel + - macos-26-intel - macos-15 + - macos-26 steps: - uses: actions/checkout@v5 diff --git a/README.md b/README.md index cf80a087..8c2915bb 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ This plugin uses embedded Turso/libSQL with native vector indexes (`F32_BLOB`, ` - Internet access on first use if you use the default local embedding model, because the model is downloaded by `@huggingface/transformers`. - For source/development installs, run `bun install` before building or testing. The published plugin package installs its runtime dependencies automatically through OpenCode. +**CI-tested platforms:** Linux, Windows, macOS 15 and macOS 26 on both Intel (`darwin/x64`) and Apple Silicon (`darwin/arm64`). Older macOS releases are not excluded by that matrix; they are simply outside the current GitHub-hosted runner set. + **Notes:** - Vector embeddings are stored and searched directly in Turso/libSQL; inserts update the vector index automatically. @@ -228,7 +230,7 @@ Example — remote OpenAI embeddings: Changing `embeddingModel` (or dimensions) can trigger re-embedding of stored memories on next startup. Prefer picking a model once and sticking with it for a given data directory. -**Intel Mac (`darwin/x64`):** newer `onnxruntime-node` builds may ship without an x64 native binding, so local embedding init can fail. Use a remote endpoint via `embeddingApiUrl` + `embeddingApiKey` (example above). If you stay on local embeddings after a plugin upgrade, clear OpenCode's nested plugin cache (`~/.cache/opencode/packages/opencode-mem@*`) so the install picks up the pinned runtime. +**Intel Mac (`darwin/x64`):** newer `onnxruntime-node` builds may ship without an x64 native binding, so local embedding init can fail. `opencode-mem` pins `onnxruntime-node@1.22.0` and loads transformers through a CJS resolve shim so OpenCode nested installs keep that binding. If init still fails after a plugin upgrade, clear OpenCode's nested plugin cache (`~/.cache/opencode/packages/opencode-mem@*`) and reinstall, or use a remote endpoint via `embeddingApiUrl` + `embeddingApiKey` (example above). ### Memory Scope @@ -349,7 +351,7 @@ Troubleshooting: - If auto-capture reports that a provider is not connected, confirm the provider name with `opencode providers list` and configure that provider in opencode first. - If a proxy or custom provider returns plain text instead of structured/tool output, choose another model/provider or use one of the manual provider modes above. - For models that reject `temperature`, add `"memoryTemperature": false` when using manual API configuration. -- **Intel Mac (darwin/x64) local embedding:** if embedding init fails (missing onnxruntime x64 binding), switch to a remote embedding endpoint via `embeddingApiUrl` + `embeddingApiKey`, or clear `~/.cache/opencode/packages/opencode-mem@*` after upgrading so the nested install picks up the pinned `onnxruntime-node`. See [Choosing / configuring embeddings](#choosing-configuring-embeddings). MLX is not supported. +- **Intel Mac (darwin/x64) local embedding:** if embedding init fails, clear `~/.cache/opencode/packages/opencode-mem@*` after upgrading so the nested install picks up the pinned `onnxruntime-node@1.22.0`, or switch to a remote embedding endpoint via `embeddingApiUrl` + `embeddingApiKey`. See [Choosing / configuring embeddings](#choosing-configuring-embeddings). MLX is not supported. ## Public Subpath Exports diff --git a/scripts/verify-embedding-backend.mjs b/scripts/verify-embedding-backend.mjs index 2b8631ce..28ebcfbc 100644 --- a/scripts/verify-embedding-backend.mjs +++ b/scripts/verify-embedding-backend.mjs @@ -4,10 +4,10 @@ * feature-extraction path loads and runs the native ONNX runtime without * crashing on the host platform. * - * This is the reproducible form of the manual checks requested when migrating - * off @xenova/transformers: the prior revert (8fb0836) was motivated by native - * ONNX runtime crashes under Windows + Bun, so this runs in CI across - * ubuntu / macOS / windows to catch a regression before merge. + * Mirrors the production loader: prefer the CJS export so OpenCode nested + * installs can pin onnxruntime-node@1.22.0 via Module._resolveFilename (#210). + * This script deliberately does not import prepareOnnxruntimeForTransformers() + * because the embedding-backend workflow runs without a TypeScript build. * * Uses a tiny model (all-MiniLM-L6-v2, ~25 MB) — the goal is to exercise the * runtime load + a real embedding call, not to validate any specific model. @@ -16,15 +16,59 @@ * `node scripts/verify-embedding-backend.mjs`. */ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; + const MODEL = "Xenova/all-MiniLM-L6-v2"; const EXPECTED_DIMS = 384; +const PINNED_ONNX_VERSION = "1.22.0"; const runtime = typeof globalThis.Bun !== "undefined" ? "bun" : "node"; console.log( `[verify-embedding] runtime=${runtime} platform=${process.platform} arch=${process.arch}` ); -const { pipeline, env } = await import("@huggingface/transformers"); +function readPackageJsonNear(entry) { + let dir = dirname(entry); + for (let i = 0; i < 6; i++) { + const candidate = join(dir, "package.json"); + if (existsSync(candidate)) { + const parsed = JSON.parse(readFileSync(candidate, "utf8")); + // onnxruntime-common ships helper package.json files under dist/* without version. + if (typeof parsed.version === "string" && parsed.version.length > 0) { + return parsed; + } + } + dir = dirname(dir); + } + throw new Error(`versioned package.json not found near ${entry}`); +} + +const requireFromHere = createRequire(import.meta.url); +const transformersSpecifier = ["@huggingface", "transformers"].join("/"); +const { pipeline, env } = requireFromHere(transformersSpecifier); + +// Assert production-shaped CJS load resolved the pinned onnxruntime stack. +const onnxEntry = requireFromHere.resolve("onnxruntime-node"); +const onnxPkg = readPackageJsonNear(onnxEntry); +if (onnxPkg.name !== "onnxruntime-node" || onnxPkg.version !== PINNED_ONNX_VERSION) { + console.error( + `[verify-embedding] FAIL: expected onnxruntime-node@${PINNED_ONNX_VERSION}, got ${onnxPkg.name}@${onnxPkg.version} at ${onnxEntry}` + ); + process.exit(1); +} +console.log(`[verify-embedding] onnxruntime-node@${onnxPkg.version} at ${onnxEntry}`); + +const commonEntry = createRequire(onnxEntry).resolve("onnxruntime-common"); +const commonPkg = readPackageJsonNear(commonEntry); +if (commonPkg.name !== "onnxruntime-common" || commonPkg.version !== PINNED_ONNX_VERSION) { + console.error( + `[verify-embedding] FAIL: expected onnxruntime-common@${PINNED_ONNX_VERSION}, got ${commonPkg.name}@${commonPkg.version} at ${commonEntry}` + ); + process.exit(1); +} +console.log(`[verify-embedding] onnxruntime-common@${commonPkg.version} at ${commonEntry}`); // Mirror the plugin's runtime configuration. env.allowLocalModels = true; diff --git a/scripts/verify-nested-onnxruntime-fixture.mjs b/scripts/verify-nested-onnxruntime-fixture.mjs new file mode 100644 index 00000000..c60319b7 --- /dev/null +++ b/scripts/verify-nested-onnxruntime-fixture.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +/** + * OpenCode-shaped nested-install regression for #210. + * + * Installs the packed plugin into a temporary consumer without root overrides, + * so @huggingface/transformers may keep nested onnxruntime-node@1.24.3. + * Then verifies the production CJS prepare+load path pins the direct 1.22.0 stack. + * + * npm may hoist dependencies to the consumer root (fixture/node_modules/...) while + * OpenCode keeps them under the plugin package. Both layouts are accepted as long as + * transformers can resolve a nested 1.24.x copy and the production shim pins 1.22.0. + * + * Usage (from a built repo checkout): + * node scripts/verify-nested-onnxruntime-fixture.mjs + * bun scripts/verify-nested-onnxruntime-fixture.mjs + * + * Optional env: + * FIXTURE_DIR — reuse an existing fixture root that already has opencode-mem installed + * SKIP_INSTALL — when FIXTURE_DIR is set, skip pack/install + * SKIP_EMBEDDING — skip the real feature-extraction smoke + * KEEP_FIXTURE — keep the temp fixture directory + */ + +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const PINNED = "1.22.0"; +const NESTED_BAD = "1.24.3"; +const runtime = typeof globalThis.Bun !== "undefined" ? "bun" : "node"; +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function log(msg) { + console.log(`[nested-onnx-fixture] ${msg}`); +} + +function fail(msg) { + console.error(`[nested-onnx-fixture] FAIL: ${msg}`); + process.exit(1); +} + +function run(cmd, args, cwd) { + const result = spawnSync(cmd, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + if (result.status !== 0) { + fail( + `${cmd} ${args.join(" ")} exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } + return result.stdout; +} + +function readPkgNear(entry) { + let dir = dirname(entry); + for (let i = 0; i < 6; i++) { + const candidate = join(dir, "package.json"); + if (existsSync(candidate)) { + const pkg = JSON.parse(readFileSync(candidate, "utf8")); + // onnxruntime-common ships helper package.json files under dist/* without version. + if (typeof pkg.version === "string" && pkg.version.length > 0) { + return { path: candidate, pkg }; + } + } + dir = dirname(dir); + } + throw new Error(`versioned package.json not found near ${entry}`); +} + +function findNestedOnnxruntime(searchRoots) { + for (const root of searchRoots) { + const nested = join( + root, + "node_modules", + "@huggingface", + "transformers", + "node_modules", + "onnxruntime-node" + ); + const pkgPath = join(nested, "package.json"); + if (existsSync(pkgPath)) { + return { + root: nested, + pkg: JSON.parse(readFileSync(pkgPath, "utf8")), + }; + } + } + return null; +} + +function findTransformersPackageJson(searchRoots) { + for (const root of searchRoots) { + const candidate = join(root, "node_modules", "@huggingface", "transformers", "package.json"); + if (existsSync(candidate)) return candidate; + } + return null; +} + +async function main() { + log(`runtime=${runtime} platform=${process.platform} arch=${process.arch}`); + + let fixtureDir = process.env.FIXTURE_DIR ? resolve(process.env.FIXTURE_DIR) : null; + let cleanup = null; + + if (!fixtureDir || process.env.SKIP_INSTALL !== "1") { + const packDir = mkdtempSync(join(tmpdir(), "opencode-mem-pack-")); + fixtureDir = mkdtempSync(join(tmpdir(), "opencode-mem-nested-")); + cleanup = () => { + rmSync(packDir, { recursive: true, force: true }); + rmSync(fixtureDir, { recursive: true, force: true }); + }; + + log(`packing from ${repoRoot}`); + run("npm", ["pack", "--pack-destination", packDir], repoRoot); + const tarball = run("bash", ["-lc", `ls "${packDir}"/opencode-mem-*.tgz | head -1`]).trim(); + if (!tarball) fail("npm pack produced no tarball"); + + writeFileSync( + join(fixtureDir, "package.json"), + JSON.stringify({ name: "opencode-mem-nested-fixture", private: true }, null, 2) + ); + // Intentionally NO root overrides — OpenCode nested installs ignore nested overrides (#184). + log(`installing ${tarball} into ${fixtureDir} (ignore-scripts, no overrides)`); + run("npm", ["install", "--ignore-scripts", tarball], fixtureDir); + } + + const pluginRoot = join(fixtureDir, "node_modules", "opencode-mem"); + if (!existsSync(pluginRoot)) fail(`plugin not installed at ${pluginRoot}`); + + const searchRoots = [pluginRoot, fixtureDir]; + const pluginRequire = createRequire(join(pluginRoot, "dist", "services", "embedding.js")); + + let directNodeEntry; + try { + directNodeEntry = pluginRequire.resolve("onnxruntime-node"); + } catch (error) { + fail(`direct onnxruntime-node not resolvable from plugin: ${error}`); + } + const directPkg = readPkgNear(directNodeEntry).pkg; + if (directPkg.name !== "onnxruntime-node" || directPkg.version !== PINNED) { + fail( + `direct onnxruntime-node is ${directPkg.name}@${directPkg.version}, expected onnxruntime-node@${PINNED}` + ); + } + log(`direct onnxruntime-node@${directPkg.version} at ${directNodeEntry}`); + + const nested = findNestedOnnxruntime(searchRoots); + if (nested) { + log(`nested onnxruntime-node@${nested.pkg.version} present under transformers`); + if (nested.pkg.version !== NESTED_BAD && nested.pkg.version !== PINNED) { + log(`warning: unexpected nested version ${nested.pkg.version}`); + } + } else { + fail( + "expected nested onnxruntime-node under @huggingface/transformers (OpenCode nested-install shape); package manager deduped unexpectedly" + ); + } + + // Prove that a transformers-local require would prefer nested 1.24.x when present. + if (nested.pkg.version !== PINNED) { + const transformersPkg = findTransformersPackageJson(searchRoots); + if (!transformersPkg) fail("transformers package.json not found"); + const nestedRequire = createRequire(transformersPkg); + const nestedResolved = nestedRequire.resolve("onnxruntime-node"); + const resolvedPkg = readPkgNear(nestedResolved).pkg; + if (resolvedPkg.version === PINNED) { + fail( + "expected transformers-local resolve to prefer nested 1.24.x before shim, but got pinned 1.22.0" + ); + } + log(`pre-shim transformers resolve -> ${nestedResolved} (@${resolvedPkg.version})`); + } + + // Load production prepare from the installed plugin dist. + const resolveUrl = pathToFileURL( + join(pluginRoot, "dist", "services", "onnxruntime-resolve.js") + ).href; + const { prepareOnnxruntimeForTransformers, getPinnedOnnxruntimePackageRoot } = + await import(resolveUrl); + + prepareOnnxruntimeForTransformers(); + + const transformersSpecifier = ["@huggingface", "transformers"].join("/"); + const transformers = pluginRequire(transformersSpecifier); + if (typeof transformers.pipeline !== "function" || !transformers.env) { + fail("CJS transformers load did not expose pipeline/env"); + } + + const pinnedNode = pluginRequire.resolve("onnxruntime-node"); + const pinnedCommon = createRequire(pinnedNode).resolve("onnxruntime-common"); + const nodePkg = readPkgNear(pinnedNode).pkg; + const commonPkg = readPkgNear(pinnedCommon).pkg; + + if (nodePkg.version !== PINNED) { + fail(`after prepare, onnxruntime-node resolved to ${nodePkg.version} at ${pinnedNode}`); + } + if (commonPkg.version !== PINNED) { + fail( + `after prepare, onnxruntime-common resolved to ${commonPkg.version} at ${pinnedCommon}` + ); + } + + const pinnedRoot = getPinnedOnnxruntimePackageRoot(); + if (!pinnedRoot.includes("onnxruntime-node")) { + fail(`unexpected pinned root: ${pinnedRoot}`); + } + + // From nested transformers context, shim must force both packages onto the pin. + const transformersEntry = pluginRequire.resolve(transformersSpecifier); + const fromTransformers = createRequire(transformersEntry); + const shimmedNode = fromTransformers.resolve("onnxruntime-node"); + const shimmedCommon = fromTransformers.resolve("onnxruntime-common"); + if (readPkgNear(shimmedNode).pkg.version !== PINNED) { + fail(`shim failed for onnxruntime-node: ${shimmedNode}`); + } + if (readPkgNear(shimmedCommon).pkg.version !== PINNED) { + fail(`shim failed for onnxruntime-common: ${shimmedCommon}`); + } + + log(`pinned node=${shimmedNode}`); + log(`pinned common=${shimmedCommon}`); + + // Real dlopen + embedding path (optional skip for unit-speed local runs). + if (process.env.SKIP_EMBEDDING !== "1") { + const MODEL = "Xenova/all-MiniLM-L6-v2"; + const EXPECTED_DIMS = 384; + transformers.env.allowLocalModels = true; + transformers.env.allowRemoteModels = true; + try { + transformers.env.backends.onnx.wasm.numThreads = 1; + } catch { + /* wasm backend optional */ + } + log(`loading feature-extraction pipeline for ${MODEL} ...`); + const extractor = await transformers.pipeline("feature-extraction", MODEL); + const out = await extractor("Hello world, nested onnxruntime fixture.", { + pooling: "mean", + normalize: true, + }); + const dims = out.dims?.[out.dims.length - 1]; + if (dims !== EXPECTED_DIMS) { + fail(`expected ${EXPECTED_DIMS} dims, got ${dims}`); + } + const vec = Array.from(out.data); + const allFinite = vec.every((x) => Number.isFinite(x)); + const norm = Math.sqrt(vec.reduce((s, x) => s + x * x, 0)); + if (!allFinite || !(norm > 0.9 && norm < 1.1)) { + fail(`bad embedding vector (finite=${allFinite}, norm=${norm})`); + } + log(`embedding ok: ${dims} dims, L2=${norm.toFixed(4)}`); + } + + log("PASS — nested fixture loads production CJS path on onnxruntime 1.22.0 stack"); + + if (cleanup && process.env.KEEP_FIXTURE !== "1") cleanup(); +} + +main().catch((error) => { + fail(error instanceof Error ? error.stack || error.message : String(error)); +}); diff --git a/src/services/embedding.ts b/src/services/embedding.ts index c6599515..1da93f2b 100644 --- a/src/services/embedding.ts +++ b/src/services/embedding.ts @@ -1,11 +1,14 @@ import { CONFIG } from "../config.js"; import { log } from "./logger.js"; +import { createRequire } from "node:module"; import { join } from "node:path"; import { - formatMissingOnnxruntimeBindingError, + formatOnnxruntimeInitError, prepareOnnxruntimeForTransformers, } from "./onnxruntime-resolve.js"; +const requireFromHere = createRequire(import.meta.url); + const TIMEOUT_MS = 30000; const GLOBAL_EMBEDDING_KEY = Symbol.for("opencode-mem.embedding.instance"); const MAX_CACHE_SIZE = 100; @@ -50,23 +53,13 @@ function getTransformersPackageSpecifier(): string { return ["@huggingface", "transformers"].join("/"); } -function rewriteOnnxruntimeInitError(error: unknown): Error { - const message = error instanceof Error ? error.message : String(error); - if ( - message.includes("onnxruntime_binding.node") || - message.includes("onnxruntime-node") || - /napi-v6\/[^/]+\/[^/]+/.test(message) - ) { - return new Error(formatMissingOnnxruntimeBindingError(), { cause: error }); - } - return error instanceof Error ? error : new Error(message); -} - async function ensureTransformersLoaded(): Promise> { if (_transformers !== null) return _transformers; - // Pin onnxruntime-node to our direct dep before transformers resolves it (#184). + // Pin onnxruntime-node (+ common) to our direct 1.22.0 stack before transformers + // resolves them (#184 / #210). Load the CJS export so Module._resolveFilename + // shim applies — the ESM entry's static import bypasses it under OpenCode nested installs. prepareOnnxruntimeForTransformers(); - const mod = (await import(getTransformersPackageSpecifier())) as HfTransformers; + const mod = requireFromHere(getTransformersPackageSpecifier()) as HfTransformers; mod.env.allowLocalModels = true; mod.env.allowRemoteModels = true; mod.env.cacheDir = join(CONFIG.storagePath, ".cache"); @@ -151,7 +144,7 @@ export class EmbeddingService { this.initError = null; log("Embedding model warmed up", { model: CONFIG.embeddingModel }); } catch (error) { - const rewritten = rewriteOnnxruntimeInitError(error); + const rewritten = formatOnnxruntimeInitError(error); this.initPromise = null; this.initError = rewritten.message; log("Failed to initialize embedding model", { error: rewritten.message }); diff --git a/src/services/onnxruntime-resolve.ts b/src/services/onnxruntime-resolve.ts index a6bd8c0f..9565baf5 100644 --- a/src/services/onnxruntime-resolve.ts +++ b/src/services/onnxruntime-resolve.ts @@ -1,23 +1,44 @@ /** - * Force resolution of `onnxruntime-node` to this package's direct dependency. + * Force resolution of `onnxruntime-node` (and its `onnxruntime-common`) to this + * package's direct dependency stack. * * OpenCode installs plugins nested under its own cache. npm/Arborist only honors * `overrides` at the install root, so `@huggingface/transformers` would otherwise - * keep nested `onnxruntime-node@1.24.3` (no darwin/x64 binding). See #184 / #158. + * keep nested `onnxruntime-node@1.24.3` (no darwin/x64 binding). See #184 / #158 / #210. + * + * Transformers must be loaded via its CJS export so this Module._resolveFilename + * shim applies; the ESM entry's static `import "onnxruntime-node"` bypasses it. */ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; const PACKAGE_NAME = "onnxruntime-node"; +const COMMON_PACKAGE = "onnxruntime-common"; const requireFromHere = createRequire(import.meta.url); let shimInstalled = false; let pinnedPackageRoot: string | null = null; +let pinnedNodeEntry: string | null = null; +let pinnedCommonEntry: string | null = null; + +function getPinnedOnnxruntimeEntry(): string { + if (pinnedNodeEntry) return pinnedNodeEntry; + pinnedNodeEntry = requireFromHere.resolve(PACKAGE_NAME); + return pinnedNodeEntry; +} + +function getPinnedOnnxruntimeCommonEntry(): string { + if (pinnedCommonEntry) return pinnedCommonEntry; + // Resolve common from the pinned node package so we always get the 1.22.0 stack, + // whether the package manager hoists it or nests it under onnxruntime-node. + pinnedCommonEntry = createRequire(getPinnedOnnxruntimeEntry()).resolve(COMMON_PACKAGE); + return pinnedCommonEntry; +} export function getPinnedOnnxruntimePackageRoot(): string { if (pinnedPackageRoot) return pinnedPackageRoot; - const entry = requireFromHere.resolve(PACKAGE_NAME); + const entry = getPinnedOnnxruntimeEntry(); // package entry is typically …/dist/index.js — walk up to package root let dir = dirname(entry); for (let i = 0; i < 4; i++) { @@ -57,6 +78,45 @@ export function formatMissingOnnxruntimeBindingError( return `Local embedding native binding missing for ${platform}/${arch} at ${bindingPath}.${intelHint}`; } +/** + * Rewrite onnxruntime-related init failures. + * + * When the pinned binding is absent, keep the clear "missing" message. + * When it is present, preserve the original error so nested-1.24 / dlopen / + * codesign failures are not misreported as a missing 1.22.0 file (#210). + */ +export function formatOnnxruntimeInitError( + error: unknown, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): Error { + const message = error instanceof Error ? error.message : String(error); + const isOnnxRelated = + message.includes("onnxruntime_binding.node") || + message.includes("onnxruntime-node") || + message.includes("onnxruntime-common") || + /napi-v6\/[^/]+\/[^/]+/.test(message); + + if (!isOnnxRelated) { + return error instanceof Error ? error : new Error(message); + } + + const bindingPath = getOnnxruntimeBindingPath(platform, arch); + if (!existsSync(bindingPath)) { + return new Error(formatMissingOnnxruntimeBindingError(platform, arch), { cause: error }); + } + + const intelHint = + platform === "darwin" && arch === "x64" + ? " On Intel Mac nested installs, @huggingface/transformers may resolve onnxruntime-node@1.24+ (no x64 binding); opencode-mem pins 1.22.0 via a CJS resolve shim." + : ""; + + return new Error( + `ONNX runtime failed to load despite pinned binding being present at ${bindingPath}. Original error: ${message}.${intelHint} If this persists after updating, clear OpenCode's plugin cache (~/.cache/opencode/packages/opencode-mem@*) and reinstall, or configure remote embeddings via embeddingApiUrl + embeddingApiKey.`, + { cause: error } + ); +} + export function assertOnnxruntimeBindingPresent( platform: NodeJS.Platform = process.platform, arch: string = process.arch @@ -67,15 +127,35 @@ export function assertOnnxruntimeBindingPresent( } } +function resolvePinnedRequest( + request: string, + packageName: string, + pinnedEntry: string +): string | null { + if (request !== packageName && !request.startsWith(`${packageName}/`)) { + return null; + } + try { + if (packageName === PACKAGE_NAME) { + return requireFromHere.resolve(request); + } + return createRequire(getPinnedOnnxruntimeEntry()).resolve(request); + } catch { + if (request === packageName) return pinnedEntry; + return null; + } +} + /** - * Patch Module._resolveFilename so require/import of "onnxruntime-node" from - * nested transformers resolves to our direct dependency. + * Patch Module._resolveFilename so require() of onnxruntime-node / onnxruntime-common + * from nested transformers resolves to our direct 1.22.0 dependency stack. */ export function installOnnxruntimeResolveShim(): void { if (shimInstalled) return; // Resolve our pin first so a missing direct dep fails before transformers loads. - const pinnedEntry = requireFromHere.resolve(PACKAGE_NAME); + const pinnedEntry = getPinnedOnnxruntimeEntry(); + const pinnedCommon = getPinnedOnnxruntimeCommonEntry(); const Module = requireFromHere("node:module") as { _resolveFilename: ( @@ -93,13 +173,12 @@ export function installOnnxruntimeResolveShim(): void { isMain: boolean, options?: unknown ): string { - if (request === PACKAGE_NAME || request.startsWith(`${PACKAGE_NAME}/`)) { - try { - return requireFromHere.resolve(request); - } catch { - if (request === PACKAGE_NAME) return pinnedEntry; - } - } + const pinnedNode = resolvePinnedRequest(request, PACKAGE_NAME, pinnedEntry); + if (pinnedNode) return pinnedNode; + + const pinnedCommonResolved = resolvePinnedRequest(request, COMMON_PACKAGE, pinnedCommon); + if (pinnedCommonResolved) return pinnedCommonResolved; + return original.call(this, request, parent, isMain, options); }; diff --git a/tests/onnxruntime-resolve.test.ts b/tests/onnxruntime-resolve.test.ts index 4a18d6b4..b4e3fc54 100644 --- a/tests/onnxruntime-resolve.test.ts +++ b/tests/onnxruntime-resolve.test.ts @@ -1,33 +1,84 @@ import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { assertOnnxruntimeBindingPresent, formatMissingOnnxruntimeBindingError, + formatOnnxruntimeInitError, getOnnxruntimeBindingPath, getPinnedOnnxruntimePackageRoot, installOnnxruntimeResolveShim, + prepareOnnxruntimeForTransformers, } from "../src/services/onnxruntime-resolve.js"; import pkg from "../package.json"; -describe("onnxruntime resolve shim (#184)", () => { +const requireFromHere = createRequire(import.meta.url); +const PINNED = pkg.dependencies["onnxruntime-node"]; + +function transformersPackageRoot(): string { + const entry = requireFromHere.resolve("@huggingface/transformers"); + let dir = dirname(entry); + for (let i = 0; i < 4; i++) { + if (existsSync(join(dir, "package.json"))) return dir; + dir = dirname(dir); + } + throw new Error(`transformers package root not found near ${entry}`); +} + +function packageVersionNear(entry: string): string { + let dir = dirname(entry); + for (let i = 0; i < 6; i++) { + const candidate = join(dir, "package.json"); + if (existsSync(candidate)) { + const parsed = JSON.parse(readFileSync(candidate, "utf8")) as { + name?: string; + version?: string; + }; + // onnxruntime-common ships helper package.json files under dist/* without version. + if (typeof parsed.version === "string" && parsed.version.length > 0) { + return parsed.version; + } + } + dir = dirname(dir); + } + throw new Error(`versioned package.json not found near ${entry}`); +} + +describe("onnxruntime resolve shim (#184 / #210)", () => { it("pins onnxruntime-node to the direct dependency package root", () => { const root = getPinnedOnnxruntimePackageRoot(); expect(root.includes("onnxruntime-node")).toBe(true); - const pinnedPkg = createRequire(import.meta.url)(`${root}/package.json`); - expect(pinnedPkg.version).toBe(pkg.dependencies["onnxruntime-node"]); + const pinnedPkg = requireFromHere(`${root}/package.json`); + expect(pinnedPkg.version).toBe(PINNED); }); it("resolve shim forces require('onnxruntime-node') onto the direct dep", () => { installOnnxruntimeResolveShim(); - const fromHere = createRequire(import.meta.url); - const pinned = fromHere.resolve("onnxruntime-node"); - // Mimic nested require context under @huggingface/transformers - const transformersPkg = fromHere.resolve("@huggingface/transformers/package.json"); - const nestedRequire = createRequire(transformersPkg); + const pinned = requireFromHere.resolve("onnxruntime-node"); + const nestedRequire = createRequire(join(transformersPackageRoot(), "package.json")); expect(nestedRequire.resolve("onnxruntime-node")).toBe(pinned); }); + it("resolve shim forces require('onnxruntime-common') onto the pinned node stack", () => { + installOnnxruntimeResolveShim(); + const pinnedNode = requireFromHere.resolve("onnxruntime-node"); + const pinnedCommon = createRequire(pinnedNode).resolve("onnxruntime-common"); + const nestedRequire = createRequire(join(transformersPackageRoot(), "package.json")); + expect(nestedRequire.resolve("onnxruntime-common")).toBe(pinnedCommon); + }); + it("binding path exists for the current platform (or documents the failure)", () => { const bindingPath = getOnnxruntimeBindingPath(); if (existsSync(bindingPath)) { @@ -46,4 +97,177 @@ describe("onnxruntime resolve shim (#184)", () => { expect(message).toContain("embeddingApiKey"); expect(message).toContain("1.22.0"); }); + + it("init error reports missing binding only when the pinned file is absent", () => { + const rewritten = formatOnnxruntimeInitError( + new Error("Cannot find module '.../napi-v6/darwin/nope/onnxruntime_binding.node'"), + "darwin", + "nope" + ); + expect(rewritten.message).toContain("Local embedding native binding missing"); + expect(rewritten.message).toContain("darwin/nope"); + }); + + it("init error preserves original cause when the pinned binding is present", () => { + const bindingPath = getOnnxruntimeBindingPath(); + if (!existsSync(bindingPath)) return; + + const original = new Error( + `dlopen(${bindingPath}): simulated nested onnxruntime-node load failure` + ); + const rewritten = formatOnnxruntimeInitError(original); + expect(rewritten.message).toContain( + "ONNX runtime failed to load despite pinned binding being present" + ); + expect(rewritten.message).toContain(bindingPath); + expect(rewritten.message).toContain("simulated nested onnxruntime-node load failure"); + expect(rewritten.message).not.toMatch(/^Local embedding native binding missing/); + expect((rewritten as Error & { cause?: unknown }).cause).toBe(original); + }); + + it("CJS transformers load after prepare exposes pipeline/env on the pinned stack", () => { + prepareOnnxruntimeForTransformers(); + const specifier = ["@huggingface", "transformers"].join("/"); + const transformers = requireFromHere(specifier) as { + pipeline: unknown; + env: unknown; + }; + expect(typeof transformers.pipeline).toBe("function"); + expect(transformers.env).toBeTruthy(); + + const pinnedNode = requireFromHere.resolve("onnxruntime-node"); + const pinnedCommon = createRequire(pinnedNode).resolve("onnxruntime-common"); + expect(packageVersionNear(pinnedNode)).toBe(PINNED); + expect(packageVersionNear(pinnedCommon)).toBe(PINNED); + }); + + it("isolated nested 1.24.3 layout is overridden by prepare shim", () => { + const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + const fixture = mkdtempSync(join(tmpdir(), "opencode-mem-nested-shim-")); + try { + const realNodeRoot = getPinnedOnnxruntimePackageRoot(); + const realNodeEntry = requireFromHere.resolve("onnxruntime-node"); + const realCommonEntry = createRequire(realNodeEntry).resolve("onnxruntime-common"); + let realCommonRoot = dirname(realCommonEntry); + for (let i = 0; i < 5; i++) { + if (existsSync(join(realCommonRoot, "package.json"))) break; + realCommonRoot = dirname(realCommonRoot); + } + + const nm = join(fixture, "node_modules"); + const transformersRoot = join(nm, "@huggingface", "transformers"); + const nestedNode = join(transformersRoot, "node_modules", "onnxruntime-node"); + const nestedCommon = join(transformersRoot, "node_modules", "onnxruntime-common"); + + mkdirSync(join(transformersRoot, "dist"), { recursive: true }); + mkdirSync(join(nestedNode, "dist"), { recursive: true }); + mkdirSync(join(nestedCommon, "dist", "cjs"), { recursive: true }); + symlinkSync(realNodeRoot, join(nm, "onnxruntime-node")); + symlinkSync(realCommonRoot, join(nm, "onnxruntime-common")); + + writeFileSync( + join(transformersRoot, "package.json"), + JSON.stringify({ + name: "@huggingface/transformers", + version: "0.0.0-fixture", + main: "./dist/transformers.node.cjs", + }) + ); + writeFileSync( + join(transformersRoot, "dist", "transformers.node.cjs"), + `module.exports = { pipeline() {}, env: {} };\n` + ); + writeFileSync( + join(nestedNode, "package.json"), + JSON.stringify({ + name: "onnxruntime-node", + version: "1.24.3", + main: "dist/index.js", + }) + ); + writeFileSync(join(nestedNode, "dist", "index.js"), `module.exports = { nested: true };\n`); + writeFileSync( + join(nestedCommon, "package.json"), + JSON.stringify({ + name: "onnxruntime-common", + version: "1.24.3", + main: "dist/cjs/index.js", + }) + ); + writeFileSync( + join(nestedCommon, "dist", "cjs", "index.js"), + `module.exports = { nested: true };\n` + ); + writeFileSync(join(fixture, "package.json"), JSON.stringify({ type: "module" })); + + const resolveModuleUrl = pathToFileURL( + join(repoRoot, "src/services/onnxruntime-resolve.ts") + ).href; + const harness = join(fixture, "harness.mjs"); + writeFileSync( + harness, + ` +import { createRequire } from "node:module"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +function pkgVersion(entry) { + let dir = dirname(entry); + for (let i = 0; i < 6; i++) { + const candidate = join(dir, "package.json"); + if (existsSync(candidate)) { + const parsed = JSON.parse(readFileSync(candidate, "utf8")); + if (typeof parsed.version === "string" && parsed.version.length > 0) return parsed.version; + } + dir = dirname(dir); + } + throw new Error("versioned package.json missing near " + entry); +} + +const fixtureRequire = createRequire(join(${JSON.stringify(transformersRoot)}, "package.json")); +const beforeNode = fixtureRequire.resolve("onnxruntime-node"); +const beforeCommon = fixtureRequire.resolve("onnxruntime-common"); +if (pkgVersion(beforeNode) !== "1.24.3") { + throw new Error("fixture did not nest onnxruntime-node@1.24.3: " + beforeNode); +} +if (pkgVersion(beforeCommon) !== "1.24.3") { + throw new Error("fixture did not nest onnxruntime-common@1.24.3: " + beforeCommon); +} + +const resolveMod = await import(${JSON.stringify(resolveModuleUrl)}); +resolveMod.prepareOnnxruntimeForTransformers(); + +const afterNode = fixtureRequire.resolve("onnxruntime-node"); +const afterCommon = fixtureRequire.resolve("onnxruntime-common"); +if (pkgVersion(afterNode) !== ${JSON.stringify(PINNED)}) { + throw new Error("node not pinned: " + afterNode + " @" + pkgVersion(afterNode)); +} +if (pkgVersion(afterCommon) !== ${JSON.stringify(PINNED)}) { + throw new Error("common not pinned: " + afterCommon + " @" + pkgVersion(afterCommon)); +} +if (afterNode.includes(${JSON.stringify(join("transformers", "node_modules"))})) { + throw new Error("node still nested after shim: " + afterNode); +} +console.log("nested-shim-ok"); +` + ); + + const result = spawnSync(process.execPath, [harness], { + encoding: "utf8", + cwd: fixture, + env: { + ...process.env, + NODE_PATH: nm, + }, + }); + if (result.status !== 0) { + throw new Error( + `nested shim harness failed (${result.status})\nstdout:${result.stdout}\nstderr:${result.stderr}` + ); + } + expect(result.stdout).toContain("nested-shim-ok"); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + }); }); From cf6330c3775f1472b43f8679de6dc71e678e4743 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Thu, 30 Jul 2026 12:50:55 +0200 Subject: [PATCH 2/2] ci(embedding): separate installation of source and web dependencies before build --- .github/workflows/embedding-backend.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/embedding-backend.yml b/.github/workflows/embedding-backend.yml index d6675d7e..02e5aaa1 100644 --- a/.github/workflows/embedding-backend.yml +++ b/.github/workflows/embedding-backend.yml @@ -100,10 +100,15 @@ jobs: node-version: "22" package-manager-cache: false - - name: Install and build package - run: | - bun install - bun run build + - name: Install source dependencies + run: bun install + + - name: Install web dependencies + working-directory: web + run: bun install + + - name: Build package + run: bun run build - name: Nested fixture + embedding (Bun 1.3.14) run: bun scripts/verify-nested-onnxruntime-fixture.mjs