Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/embedding-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -23,7 +24,9 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-15, windows-latest]
# 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]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions src/services/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
29 changes: 27 additions & 2 deletions src/services/embedding.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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<NonNullable<typeof _transformers>> {
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;
Expand All @@ -47,6 +65,8 @@ export class EmbeddingService {
private pipe: any = null;
private initPromise: Promise<void> | null = null;
public isWarmedUp: boolean = false;
/** Set when warmup fails permanently; prevents "initializing forever" (#184). */
public initError: string | null = null;
private cache: Map<string, Float32Array> = new Map();
private cachedModelName: string | null = null;

Expand All @@ -59,6 +79,7 @@ export class EmbeddingService {

async warmup(progressCallback?: (progress: any) => void): Promise<void> {
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;
Expand Down Expand Up @@ -91,6 +112,7 @@ export class EmbeddingService {
}

this.isWarmedUp = true;
this.initError = null;
return;
}

Expand All @@ -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;
}
}

Expand Down
113 changes: 113 additions & 0 deletions src/services/onnxruntime-resolve.ts
Original file line number Diff line number Diff line change
@@ -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();
}
49 changes: 49 additions & 0 deletions tests/onnxruntime-resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
9 changes: 9 additions & 0 deletions tests/package-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }).overrides?.["onnxruntime-node"]).toBe(
"1.22.0"
);
});
});