From 258238c3a0fbd68b58e93f112f00d2bb1bed0408 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Sat, 25 Jul 2026 21:23:24 +0200 Subject: [PATCH 1/3] fix(embedding): pin onnxruntime-node for Intel Mac nested installs (#184) Make onnxruntime-node@1.22.0 a direct dependency and resolve-shim it before loading transformers, so OpenCode's nested plugin install (which ignores package overrides) still gets a darwin/x64 binding. Fail fast with a clear remote-embedding hint instead of leaving memory stuck initializing. Co-authored-by: Cursor --- .github/workflows/embedding-backend.yml | 5 +- bun.lock | 1 + package.json | 3 +- src/index.ts | 5 ++ src/services/client.ts | 6 ++ src/services/embedding.ts | 29 +++++- src/services/onnxruntime-resolve.ts | 113 ++++++++++++++++++++++++ tests/onnxruntime-resolve.test.ts | 49 ++++++++++ tests/package-dependencies.test.ts | 9 ++ 9 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 src/services/onnxruntime-resolve.ts create mode 100644 tests/onnxruntime-resolve.test.ts diff --git a/.github/workflows/embedding-backend.yml b/.github/workflows/embedding-backend.yml index f980fbeb..2436f9f4 100644 --- a/.github/workflows/embedding-backend.yml +++ b/.github/workflows/embedding-backend.yml @@ -13,6 +13,7 @@ on: - "package.json" - "bun.lock" - "src/services/embedding.ts" + - "src/services/onnxruntime-resolve.ts" - "scripts/verify-embedding-backend.mjs" - ".github/workflows/embedding-backend.yml" workflow_dispatch: @@ -23,7 +24,9 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-15, windows-latest] + # macos-13 = Intel (darwin/x64); macos-15 = Apple Silicon (darwin/arm64). + # Intel must keep resolving onnxruntime-node@1.22.0 (#184). + os: [ubuntu-latest, macos-13, macos-15, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 diff --git a/bun.lock b/bun.lock index 3be9b8f5..d01672ec 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@opencode-ai/sdk": "^1.18.4", "franc-min": "^6.2.0", "iso-639-3": "^3.0.1", + "onnxruntime-node": "1.22.0", "zod": "^4.4.3", }, "devDependencies": { diff --git a/package.json b/package.json index 7d1b01e1..ff29a57b 100644 --- a/package.json +++ b/package.json @@ -54,9 +54,10 @@ "@opencode-ai/sdk": "^1.18.4", "franc-min": "^6.2.0", "iso-639-3": "^3.0.1", + "onnxruntime-node": "1.22.0", "zod": "^4.4.3" }, - "//": "Remove onnxruntime-node override after https://github.com/microsoft/onnxruntime/issues/27961 is resolved", + "//": "Pin onnxruntime-node@1.22.0 (direct + override) until https://github.com/microsoft/onnxruntime/issues/27961 is resolved. Direct dep is required because OpenCode installs plugins nested and npm ignores nested package overrides (#184).", "overrides": { "onnxruntime-node": "1.22.0" }, diff --git a/src/index.ts b/src/index.ts index 97ad9eef..5c3867d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -464,6 +464,11 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { }); } + const embeddingInitError = memoryClient.getEmbeddingInitError(); + if (embeddingInitError) { + return JSON.stringify({ success: false, error: embeddingInitError }); + } + try { await memoryClient.warmup(); } catch (error) { diff --git a/src/services/client.ts b/src/services/client.ts index e4392851..19c9e33d 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -80,15 +80,21 @@ export class LocalMemoryClient { return this.isInitialized && embeddingService.isWarmedUp; } + getEmbeddingInitError(): string | null { + return embeddingService.initError; + } + getStatus(): { dbConnected: boolean; modelLoaded: boolean; ready: boolean; + embeddingError: string | null; } { return { dbConnected: this.isInitialized, modelLoaded: embeddingService.isWarmedUp, ready: this.isInitialized && embeddingService.isWarmedUp, + embeddingError: embeddingService.initError, }; } diff --git a/src/services/embedding.ts b/src/services/embedding.ts index f0d132e8..be9bb179 100644 --- a/src/services/embedding.ts +++ b/src/services/embedding.ts @@ -1,6 +1,10 @@ import { CONFIG } from "../config.js"; import { log } from "./logger.js"; import { join } from "node:path"; +import { + formatMissingOnnxruntimeBindingError, + prepareOnnxruntimeForTransformers, +} from "./onnxruntime-resolve.js"; const TIMEOUT_MS = 30000; const GLOBAL_EMBEDDING_KEY = Symbol.for("opencode-mem.embedding.instance"); @@ -20,8 +24,22 @@ 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). + prepareOnnxruntimeForTransformers(); const mod = (await import(getTransformersPackageSpecifier())) as HfTransformers; mod.env.allowLocalModels = true; mod.env.allowRemoteModels = true; @@ -47,6 +65,8 @@ export class EmbeddingService { private pipe: any = null; private initPromise: Promise | null = null; public isWarmedUp: boolean = false; + /** Set when warmup fails permanently; prevents "initializing forever" (#184). */ + public initError: string | null = null; private cache: Map = new Map(); private cachedModelName: string | null = null; @@ -59,6 +79,7 @@ export class EmbeddingService { async warmup(progressCallback?: (progress: any) => void): Promise { if (this.isWarmedUp) return; + if (this.initError) throw new Error(this.initError); if (this.initPromise) return this.initPromise; this.initPromise = this.initializeModel(progressCallback); return this.initPromise; @@ -91,6 +112,7 @@ export class EmbeddingService { } this.isWarmedUp = true; + this.initError = null; return; } @@ -100,11 +122,14 @@ export class EmbeddingService { progress_callback: progressCallback, }); this.isWarmedUp = true; + this.initError = null; log("Embedding model warmed up", { model: CONFIG.embeddingModel }); } catch (error) { + const rewritten = rewriteOnnxruntimeInitError(error); this.initPromise = null; - log("Failed to initialize embedding model", { error: String(error) }); - throw error; + this.initError = rewritten.message; + log("Failed to initialize embedding model", { error: rewritten.message }); + throw rewritten; } } diff --git a/src/services/onnxruntime-resolve.ts b/src/services/onnxruntime-resolve.ts new file mode 100644 index 00000000..a6bd8c0f --- /dev/null +++ b/src/services/onnxruntime-resolve.ts @@ -0,0 +1,113 @@ +/** + * Force resolution of `onnxruntime-node` to this package's direct dependency. + * + * 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. + */ +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const PACKAGE_NAME = "onnxruntime-node"; +const requireFromHere = createRequire(import.meta.url); + +let shimInstalled = false; +let pinnedPackageRoot: string | null = null; + +export function getPinnedOnnxruntimePackageRoot(): string { + if (pinnedPackageRoot) return pinnedPackageRoot; + const entry = requireFromHere.resolve(PACKAGE_NAME); + // package entry is typically …/dist/index.js — walk up to package root + let dir = dirname(entry); + for (let i = 0; i < 4; i++) { + if (existsSync(join(dir, "package.json"))) { + pinnedPackageRoot = dir; + return dir; + } + dir = dirname(dir); + } + pinnedPackageRoot = dirname(entry); + return pinnedPackageRoot; +} + +export function getOnnxruntimeBindingPath( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + return join( + getPinnedOnnxruntimePackageRoot(), + "bin", + "napi-v6", + platform, + arch, + "onnxruntime_binding.node" + ); +} + +export function formatMissingOnnxruntimeBindingError( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + const bindingPath = getOnnxruntimeBindingPath(platform, arch); + const intelHint = + platform === "darwin" && arch === "x64" + ? " On Intel Mac (darwin/x64), onnxruntime-node@1.24+ ships without an x64 binding; opencode-mem pins 1.22.0. If this persists after updating, clear OpenCode's plugin cache (~/.cache/opencode/packages/opencode-mem@*) and reinstall, or configure remote embeddings via embeddingApiUrl + embeddingApiKey." + : " Configure remote embeddings via embeddingApiUrl + embeddingApiKey, or reinstall the plugin so onnxruntime-node@1.22.0 is used."; + return `Local embedding native binding missing for ${platform}/${arch} at ${bindingPath}.${intelHint}`; +} + +export function assertOnnxruntimeBindingPresent( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): void { + const bindingPath = getOnnxruntimeBindingPath(platform, arch); + if (!existsSync(bindingPath)) { + throw new Error(formatMissingOnnxruntimeBindingError(platform, arch)); + } +} + +/** + * Patch Module._resolveFilename so require/import of "onnxruntime-node" from + * nested transformers resolves to our direct dependency. + */ +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 Module = requireFromHere("node:module") as { + _resolveFilename: ( + request: string, + parent: unknown, + isMain: boolean, + options?: unknown + ) => string; + }; + + const original = Module._resolveFilename; + Module._resolveFilename = function ( + request: string, + parent: unknown, + 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; + } + } + return original.call(this, request, parent, isMain, options); + }; + + shimInstalled = true; +} + +/** Install resolve shim and fail fast if the platform binding is absent. */ +export function prepareOnnxruntimeForTransformers(): void { + installOnnxruntimeResolveShim(); + assertOnnxruntimeBindingPresent(); +} diff --git a/tests/onnxruntime-resolve.test.ts b/tests/onnxruntime-resolve.test.ts new file mode 100644 index 00000000..4a18d6b4 --- /dev/null +++ b/tests/onnxruntime-resolve.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { + assertOnnxruntimeBindingPresent, + formatMissingOnnxruntimeBindingError, + getOnnxruntimeBindingPath, + getPinnedOnnxruntimePackageRoot, + installOnnxruntimeResolveShim, +} from "../src/services/onnxruntime-resolve.js"; +import pkg from "../package.json"; + +describe("onnxruntime resolve shim (#184)", () => { + 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"]); + }); + + 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); + expect(nestedRequire.resolve("onnxruntime-node")).toBe(pinned); + }); + + it("binding path exists for the current platform (or documents the failure)", () => { + const bindingPath = getOnnxruntimeBindingPath(); + if (existsSync(bindingPath)) { + expect(() => assertOnnxruntimeBindingPresent()).not.toThrow(); + } else { + expect(() => assertOnnxruntimeBindingPresent()).toThrow( + /Local embedding native binding missing/ + ); + } + }); + + it("intel mac missing-binding message mentions remote embedding fallback", () => { + const message = formatMissingOnnxruntimeBindingError("darwin", "x64"); + expect(message).toContain("darwin/x64"); + expect(message).toContain("embeddingApiUrl"); + expect(message).toContain("embeddingApiKey"); + expect(message).toContain("1.22.0"); + }); +}); diff --git a/tests/package-dependencies.test.ts b/tests/package-dependencies.test.ts index fc072727..086b6dab 100644 --- a/tests/package-dependencies.test.ts +++ b/tests/package-dependencies.test.ts @@ -11,4 +11,13 @@ describe("published dependency constraints", () => { expect(pkg.dependencies["@huggingface/transformers"]).toMatch(/^\^?4\./); expect(pkg.dependencies).not.toHaveProperty("@xenova/transformers"); }); + + it("pins onnxruntime-node@1.22.0 as a direct dependency (OpenCode nested install ignores overrides)", () => { + // Nested package.json overrides are ignored by npm/Arborist (#184). A direct + // dependency is required so Intel Mac (darwin/x64) gets a shipping binding. + expect(pkg.dependencies["onnxruntime-node"]).toBe("1.22.0"); + expect((pkg as { overrides?: Record }).overrides?.["onnxruntime-node"]).toBe( + "1.22.0" + ); + }); }); From 9a8f200e2ad4e871b013ec7e4faa46a54b35743b Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 28 Jul 2026 14:49:02 +0200 Subject: [PATCH 2/3] fix(embedding): tolerate clients without init-error accessor Keep existing runtime mocks and compatible client implementations working while still surfacing native embedding initialization failures when supported. Co-authored-by: Cursor --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 5c3867d3..0d755b7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -464,7 +464,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { }); } - const embeddingInitError = memoryClient.getEmbeddingInitError(); + const embeddingInitError = memoryClient.getEmbeddingInitError?.(); if (embeddingInitError) { return JSON.stringify({ success: false, error: embeddingInitError }); } From 4192733a5a258f266d4a5c12f990a012289be566 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 28 Jul 2026 14:53:37 +0200 Subject: [PATCH 3/3] ci(embedding): use current Intel macOS runner Replace the retired macOS 13 label so the Intel embedding verification can run instead of remaining queued indefinitely. Co-authored-by: Cursor --- .github/workflows/embedding-backend.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/embedding-backend.yml b/.github/workflows/embedding-backend.yml index 2436f9f4..be5cdd89 100644 --- a/.github/workflows/embedding-backend.yml +++ b/.github/workflows/embedding-backend.yml @@ -24,9 +24,9 @@ jobs: strategy: fail-fast: false matrix: - # macos-13 = Intel (darwin/x64); macos-15 = Apple Silicon (darwin/arm64). + # 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-13, macos-15, windows-latest] + os: [ubuntu-latest, macos-15-intel, macos-15, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5