Skip to content

Commit 4e580f9

Browse files
committed
feat(embedding): migrate @xenova/transformers → @huggingface/transformers@^4
Retry of #90 (reverted in 8fb0836) on the v4 line. Per maintainer guidance in the discussion issue: the revert was specific to @huggingface/transformers @4.0.1's native ONNX runtime crashing under Windows + Bun, not a permanent rejection. v4.2.0 ships a newer onnxruntime-node and uses sharp@^0.34.x. ## Why `@xenova/transformers@2.17.2` is frozen and pins `sharp@^0.32.0`, which builds its native binary via a postinstall script. OpenCode installs plugins with lifecycle scripts skipped, so on macOS ARM64 the sharp binary is never created and the plugin throws at load (#94, #97). `@huggingface/transformers@4.2.0` pins `sharp@^0.34.5`, whose native binaries ship as prebuilt `@img/*` optional packages (no install script) — so the backend installs cleanly under a script-skipping plugin installer. ## Changes (minimal — mirrors #90) - `package.json`: `@xenova/transformers@^2.17.2` → `@huggingface/transformers@^4.2.0` - `src/services/embedding.ts`: package specifier + types (`pipeline`/`env` API is identical). Lazy dynamic import is preserved — the specifier is still built from a non-literal array so the plugin-loader bundle never eagerly traverses the embedding stack. - guard tests flipped to expect the new backend (and still forbid the old one) - `scripts/verify-embedding-backend.mjs`: reproducible feature-extraction smoke - `.github/workflows/embedding-backend.yml`: CI matrix (ubuntu/macOS/windows) ## Verification Addresses each requested check: - **lazy loading preserved** — `plugin-bundle-boundary` test still passes (no transformer internals in the plugin-loader bundle) - **`bun install --ignore-scripts`** — prebuilt `@img/sharp-*` + `onnxruntime-node` binaries present without postinstall (asserted in CI) - **plugin import/load** — `bun run build` + full `bun test` (143 pass) green - **real feature-extraction call** — `verify-embedding-backend.mjs` loads the ONNX runtime and embeds (EN + non-EN), checks dims + L2 norm - **macOS ARM64** — verified locally (bun + node) - **Windows + Bun** — covered by the CI matrix (`windows-latest`), since the prior revert was motivated by native ONNX crashes there. This is the gate that should be green before merge. I can only verify macOS ARM64 on my own hardware; the Windows/Linux evidence comes from the CI matrix in this PR so it's reproducible rather than a claim. If the Windows job is green here, the v4.0.1-era regression is resolved. Closes #94, closes #97.
1 parent 84790b3 commit 4e580f9

7 files changed

Lines changed: 255 additions & 127 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Embedding Backend Verification
2+
3+
# Verifies the local @huggingface/transformers embedding backend installs and
4+
# runs across platforms — specifically that the native ONNX runtime + prebuilt
5+
# sharp (@img/*) load under a script-skipping install on macOS, Linux and
6+
# Windows. The migration off @xenova/transformers was previously reverted
7+
# (8fb0836) due to native ONNX crashes under Windows + Bun, so Windows is a
8+
# first-class target here, not an afterthought.
9+
10+
on:
11+
pull_request:
12+
paths:
13+
- "package.json"
14+
- "bun.lock"
15+
- "src/services/embedding.ts"
16+
- "scripts/verify-embedding-backend.mjs"
17+
- ".github/workflows/embedding-backend.yml"
18+
workflow_dispatch:
19+
20+
jobs:
21+
verify:
22+
name: ${{ matrix.os }} / bun
23+
strategy:
24+
fail-fast: false
25+
matrix:
26+
os: [ubuntu-latest, macos-latest, windows-latest]
27+
runs-on: ${{ matrix.os }}
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- uses: oven-sh/setup-bun@v2
32+
33+
# Mimic OpenCode's plugin install, which does not run lifecycle scripts.
34+
# The whole point is that prebuilt @img/sharp-* and onnxruntime-node
35+
# binaries must arrive without a postinstall build step.
36+
- name: Install (no scripts — mimics OpenCode plugin install)
37+
run: bun install --ignore-scripts
38+
39+
- name: Assert native binaries present without postinstall
40+
shell: bash
41+
run: |
42+
set -e
43+
echo "Checking prebuilt sharp (@img) ..."
44+
ls node_modules/@img/sharp-*/lib/*.node
45+
echo "Checking onnxruntime-node native binding ..."
46+
# path varies by platform/napi version — just require at least one .node
47+
found=$(find node_modules/onnxruntime-node/bin -name '*.node' | head -1)
48+
test -n "$found" && echo "onnxruntime binding: $found"
49+
50+
- name: Build
51+
run: bun run build
52+
53+
- name: Test suite
54+
run: bun test
55+
56+
# The revert-critical check: load the ONNX runtime and run a real
57+
# feature-extraction embedding under Bun on this platform.
58+
- name: Embedding smoke (Bun)
59+
run: bun scripts/verify-embedding-backend.mjs
60+
61+
# Node path too — OpenCode's plugin sidecar is Node-based in 1.15.x.
62+
- name: Setup Node.js
63+
uses: actions/setup-node@v4
64+
with:
65+
node-version: "24"
66+
67+
- name: Embedding smoke (Node)
68+
run: node scripts/verify-embedding-backend.mjs

bun.lock

Lines changed: 102 additions & 114 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"iso-639-3": "^3.0.1",
5454
"usearch": "^2.21.4",
5555
"zod": "^4.3.6",
56-
"@xenova/transformers": "^2.17.2"
56+
"@huggingface/transformers": "^4.2.0"
5757
},
5858
"devDependencies": {
5959
"@types/bun": "^1.3.8",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Embedding-backend smoke test — verifies the local @huggingface/transformers
4+
* feature-extraction path loads and runs the native ONNX runtime without
5+
* crashing on the host platform.
6+
*
7+
* This is the reproducible form of the manual checks requested when migrating
8+
* off @xenova/transformers: the prior revert (8fb0836) was motivated by native
9+
* ONNX runtime crashes under Windows + Bun, so this runs in CI across
10+
* ubuntu / macOS / windows to catch a regression before merge.
11+
*
12+
* Uses a tiny model (all-MiniLM-L6-v2, ~25 MB) — the goal is to exercise the
13+
* runtime load + a real embedding call, not to validate any specific model.
14+
*
15+
* Run with either `bun scripts/verify-embedding-backend.mjs` or
16+
* `node scripts/verify-embedding-backend.mjs`.
17+
*/
18+
19+
const MODEL = "Xenova/all-MiniLM-L6-v2";
20+
const EXPECTED_DIMS = 384;
21+
22+
const runtime = typeof globalThis.Bun !== "undefined" ? "bun" : "node";
23+
console.log(
24+
`[verify-embedding] runtime=${runtime} platform=${process.platform} arch=${process.arch}`
25+
);
26+
27+
const { pipeline, env } = await import("@huggingface/transformers");
28+
29+
// Mirror the plugin's runtime configuration.
30+
env.allowLocalModels = true;
31+
env.allowRemoteModels = true;
32+
try {
33+
env.backends.onnx.wasm.numThreads = 1;
34+
} catch {
35+
/* not fatal — only relevant for the wasm backend */
36+
}
37+
38+
console.log(`[verify-embedding] loading feature-extraction pipeline for ${MODEL} ...`);
39+
const extractor = await pipeline("feature-extraction", MODEL);
40+
41+
const samples = [
42+
"Hello world, this is a test.",
43+
"Hallo Welt, dies ist ein Test.", // non-English: exercises the multilingual tokenizer path
44+
];
45+
46+
for (const text of samples) {
47+
const out = await extractor(text, { pooling: "mean", normalize: true });
48+
const dims = out.dims?.[out.dims.length - 1];
49+
if (dims !== EXPECTED_DIMS) {
50+
console.error(`[verify-embedding] FAIL: expected ${EXPECTED_DIMS} dims, got ${dims}`);
51+
process.exit(1);
52+
}
53+
const vec = Array.from(out.data);
54+
const allFinite = vec.every((x) => Number.isFinite(x));
55+
const norm = Math.sqrt(vec.reduce((s, x) => s + x * x, 0));
56+
if (!allFinite || !(norm > 0.9 && norm < 1.1)) {
57+
console.error(
58+
`[verify-embedding] FAIL: bad vector (finite=${allFinite}, norm=${norm.toFixed(4)})`
59+
);
60+
process.exit(1);
61+
}
62+
console.log(
63+
`[verify-embedding] ok: "${text.slice(0, 24)}..." -> ${dims} dims, L2=${norm.toFixed(4)}`
64+
);
65+
}
66+
67+
console.log("[verify-embedding] PASS — ONNX runtime loaded and embeddings produced.");

src/services/embedding.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,23 @@ const TIMEOUT_MS = 30000;
66
const GLOBAL_EMBEDDING_KEY = Symbol.for("opencode-mem.embedding.instance");
77
const MAX_CACHE_SIZE = 100;
88

9-
type XenovaTransformers = typeof import("@xenova/transformers");
9+
type HfTransformers = typeof import("@huggingface/transformers");
1010

1111
let _transformers: {
12-
pipeline: XenovaTransformers["pipeline"];
13-
env: XenovaTransformers["env"];
12+
pipeline: HfTransformers["pipeline"];
13+
env: HfTransformers["env"];
1414
} | null = null;
1515

1616
function getTransformersPackageSpecifier(): string {
1717
// Keep this non-literal so OpenCode/Bun plugin-loader bundling does not eagerly
18-
// traverse @xenova/transformers internals during plugin startup. The package
18+
// traverse @huggingface/transformers internals during plugin startup. The package
1919
// is only needed for the local embedding backend, and should stay lazy.
20-
return ["@xenova", "transformers"].join("/");
20+
return ["@huggingface", "transformers"].join("/");
2121
}
2222

2323
async function ensureTransformersLoaded(): Promise<NonNullable<typeof _transformers>> {
2424
if (_transformers !== null) return _transformers;
25-
const mod = (await import(getTransformersPackageSpecifier())) as XenovaTransformers;
25+
const mod = (await import(getTransformersPackageSpecifier())) as HfTransformers;
2626
mod.env.allowLocalModels = true;
2727
mod.env.allowRemoteModels = true;
2828
mod.env.cacheDir = join(CONFIG.storagePath, ".cache");

tests/package-dependencies.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { describe, expect, it } from "bun:test";
22
import pkg from "../package.json";
33

44
describe("published dependency constraints", () => {
5-
it("uses Xenova transformers as the stable local embedding backend", () => {
6-
expect(pkg.dependencies["@xenova/transformers"]).toBe("^2.17.2");
7-
expect(pkg.dependencies).not.toHaveProperty("@huggingface/transformers");
5+
it("uses @huggingface/transformers (v4+) as the local embedding backend", () => {
6+
// Migrated from @xenova/transformers@^2.17.2 (frozen, sharp@^0.32 postinstall)
7+
// to @huggingface/transformers@^4 (active successor, sharp@^0.34 prebuilt @img,
8+
// installs cleanly under script-skipping plugin installers). The earlier revert
9+
// (8fb0836) was specific to the v4.0.1 native-ONNX runtime on Windows, not a
10+
// permanent rejection — see PR for the platform verification matrix.
11+
expect(pkg.dependencies["@huggingface/transformers"]).toMatch(/^\^?4\./);
12+
expect(pkg.dependencies).not.toHaveProperty("@xenova/transformers");
813
});
914
});

tests/plugin-bundle-boundary.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ describe("OpenCode plugin loader bundle boundary", () => {
1010

1111
expect(result.success).toBe(true);
1212
const output = await result.outputs[0]!.text();
13-
expect(output).not.toContain("node_modules/@xenova/transformers");
14-
expect(output).not.toContain("@xenova/transformers/src");
15-
expect(output).not.toContain("@xenova/transformers/dist");
1613
expect(output).not.toContain("node_modules/@huggingface/transformers");
14+
expect(output).not.toContain("@huggingface/transformers/src");
1715
expect(output).not.toContain("@huggingface/transformers/dist");
16+
// Guard against the old backend silently coming back too.
17+
expect(output).not.toContain("node_modules/@xenova/transformers");
1818
});
1919
});

0 commit comments

Comments
 (0)