fix(mnemopi): restored local fastembed runtime

- Preserved fastembed's transitive ONNX Runtime instead of forcing an ABI-mismatched runtime cache install.\n- Repaired stale fastembed model caches by downloading missing tokenizer sidecars from the matching Hugging Face model repo.\n- Added regression coverage for the runtime install plan and tokenizer sidecar repair.\n\nFixes #3054
This commit is contained in:
roboomp
2026-06-19 15:14:38 +00:00
parent 051110cfe6
commit 156ec3c363
8 changed files with 143 additions and 32 deletions
+1 -1
View File
@@ -165,7 +165,7 @@
},
"peerDependencies": {
"fastembed": "2.1.0",
"onnxruntime-node": "1.26.0",
"onnxruntime-node": "1.21.0",
},
"optionalPeers": [
"fastembed",
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed local fastembed startup on macOS ARM64 by letting `fastembed@2.1.0` install its matching `onnxruntime-node@1.21.0` native runtime instead of forcing `1.26.0`, and by repairing missing tokenizer sidecars from the upstream Hugging Face model cache when a stale fastembed archive lacks them. ([#3054](https://github.com/can1357/oh-my-pi/issues/3054))
## [16.0.6] - 2026-06-18
### Fixed
+1 -1
View File
@@ -46,7 +46,7 @@
},
"peerDependencies": {
"fastembed": "2.1.0",
"onnxruntime-node": "1.26.0"
"onnxruntime-node": "1.21.0"
},
"peerDependenciesMeta": {
"fastembed": {
+13 -1
View File
@@ -11,6 +11,7 @@ import {
} from "@oh-my-pi/pi-utils";
import type { EmbeddingModel } from "fastembed";
import { LRUCache } from "lru-cache/raw";
import { ensureFastembedTokenizerSidecars } from "./fastembed-model-cache";
import { loadFastembed } from "./fastembed-runtime";
import {
type EmbeddingOutput,
@@ -62,7 +63,18 @@ let nextProviderId = 1;
async function defaultLocalModelInitializer(options: LocalModelInitOptions): Promise<LocalEmbeddingModel> {
const { FlagEmbedding } = await loadFastembed();
return FlagEmbedding.init(options);
try {
return await FlagEmbedding.init(options);
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (
!/(?:Tokenizer file not found at .*tokenizer|Tokens map file not found at .*special_tokens_map)/u.test(message)
) {
throw error;
}
if (!(await ensureFastembedTokenizerSidecars(options.model, options.cacheDir))) throw error;
return FlagEmbedding.init(options);
}
}
function activeEmbeddingOptions() {
@@ -0,0 +1,34 @@
import * as path from "node:path";
const FASTEMBED_TOKENIZER_SIDECARS = ["tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"] as const;
const FASTEMBED_HF_REPOS: Record<string, string> = {
"fast-all-MiniLM-L6-v2": "sentence-transformers/all-MiniLM-L6-v2",
"fast-bge-base-en": "BAAI/bge-base-en",
"fast-bge-base-en-v1.5": "BAAI/bge-base-en-v1.5",
"fast-bge-small-en": "BAAI/bge-small-en",
"fast-bge-small-en-v1.5": "BAAI/bge-small-en-v1.5",
"fast-bge-small-zh-v1.5": "BAAI/bge-small-zh-v1.5",
"fast-multilingual-e5-large": "intfloat/multilingual-e5-large",
};
/** Download missing tokenizer sidecars into a fastembed model cache directory. */
export async function ensureFastembedTokenizerSidecars(model: string, cacheDir = "local_cache"): Promise<boolean> {
const repo = FASTEMBED_HF_REPOS[model];
if (repo === undefined) return false;
const modelDir = path.join(cacheDir, model);
for (const fileName of FASTEMBED_TOKENIZER_SIDECARS) {
const target = path.join(modelDir, fileName);
if (await Bun.file(target).exists()) continue;
const response = await fetch(`https://huggingface.co/${repo}/resolve/main/${fileName}`);
if (!response.ok) {
throw new Error(
`Failed to download ${model} ${fileName} from ${repo}: ${response.status} ${response.statusText}`,
);
}
await Bun.write(target, await response.arrayBuffer());
}
return true;
}
+13 -13
View File
@@ -22,27 +22,27 @@ export interface FastembedRuntimeInstallPlan {
}
/**
* `fastembed` and `onnxruntime-node` are optional peers (~270MB of native
* assets across platforms), never bundled and never installed eagerly. When
* the direct import cannot resolve — bundled `dist/cli.js`, compiled binary,
* a consumer that skipped the optional peers, or a native loader failure from
* fastembed's nested ORT — the pinned pair is `bun install`ed into a
* per-version runtime cache on first use and loaded from there (#2389, #2920).
* `fastembed` is an optional peer (~270MB of native assets across platforms),
* never bundled and never installed eagerly. When the direct import cannot
* resolve — bundled `dist/cli.js`, compiled binary, a consumer that skipped the
* optional peer, or a native loader failure — fastembed is `bun install`ed into
* a per-version runtime cache on first use and loaded from there (#2389).
*
* The pins live in `peerDependencies` as exact versions (not `catalog:`) so
* this module reads concrete specs even when the workspace manifest is
* inlined into a bundle; a workspace test asserts they match the catalog.
* The fastembed pin lives in `peerDependencies` as an exact version (not
* `catalog:`) so this module reads a concrete spec even when the workspace
* manifest is inlined into a bundle. The runtime install deliberately does not
* override fastembed's `onnxruntime-node` dependency: the prebuilt native addon
* links against that package's bundled ORT dylib/so/dll name.
*/
const FASTEMBED_SPEC = packageManifest.peerDependencies.fastembed;
const ORT_SPEC = packageManifest.peerDependencies["onnxruntime-node"];
/** Build the deterministic fastembed runtime install plan used by local embeddings. */
export function fastembedRuntimeInstallPlan(): FastembedRuntimeInstallPlan {
return {
versionKey: `fastembed-${FASTEMBED_SPEC}_ort-${ORT_SPEC}_forced-ort`.replace(/[^A-Za-z0-9._-]/g, "_"),
versionKey: `fastembed-${FASTEMBED_SPEC}_transitive-ort`.replace(/[^A-Za-z0-9._-]/g, "_"),
install: {
dependencies: { fastembed: FASTEMBED_SPEC, "onnxruntime-node": ORT_SPEC },
overrides: { "onnxruntime-common": ORT_SPEC, "onnxruntime-node": ORT_SPEC },
dependencies: { fastembed: FASTEMBED_SPEC },
trustedDependencies: ["onnxruntime-node"],
},
};
}
@@ -0,0 +1,61 @@
import { describe, expect, it, spyOn } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { ensureFastembedTokenizerSidecars } from "../src/core/fastembed-model-cache";
describe("fastembed model cache repair", () => {
it("downloads missing tokenizer sidecars without overwriting cached files", async () => {
const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), "mnemopi-fastembed-"));
const model = "fast-bge-base-en-v1.5";
const modelDir = path.join(cacheDir, model);
const requested: string[] = [];
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
(input: string | URL | Request, _init?: RequestInit) => {
const url = String(input);
requested.push(url);
return Promise.resolve(new Response(`body:${path.basename(url)}`));
},
{ preconnect: globalThis.fetch.preconnect },
),
);
try {
await fs.mkdir(modelDir, { recursive: true });
await Bun.write(path.join(modelDir, "tokenizer.json"), "cached-tokenizer");
expect(await ensureFastembedTokenizerSidecars(model, cacheDir)).toBe(true);
expect(requested).toEqual([
"https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/tokenizer_config.json",
"https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/special_tokens_map.json",
]);
expect(await Bun.file(path.join(modelDir, "tokenizer.json")).text()).toBe("cached-tokenizer");
expect(await Bun.file(path.join(modelDir, "tokenizer_config.json")).text()).toBe("body:tokenizer_config.json");
expect(await Bun.file(path.join(modelDir, "special_tokens_map.json")).text()).toBe(
"body:special_tokens_map.json",
);
} finally {
fetchSpy.mockRestore();
await fs.rm(cacheDir, { recursive: true, force: true });
}
});
it("reports unsupported fastembed cache names without network access", async () => {
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
() => {
throw new Error("fetch should not run");
},
{ preconnect: globalThis.fetch.preconnect },
),
);
try {
expect(await ensureFastembedTokenizerSidecars("unknown-model", "/tmp/missing")).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
});
+16 -16
View File
@@ -3,19 +3,17 @@ import rootManifest from "../../../package.json" with { type: "json" };
import packageManifest from "../package.json" with { type: "json" };
import { fastembedRuntimeInstallPlan } from "../src/core/fastembed-runtime";
// The fastembed/onnxruntime-node peers are pinned as exact versions (not
// `catalog:`) because `core/fastembed-runtime.ts` reads them to `bun install`
// the on-demand embedding runtime — including from bundles where the inlined
// manifest would otherwise carry an uninstallable `catalog:` spec (#2389).
// This pins the contract: the runtime install materializes exactly the
// versions the workspace develops and tests against, and forces fastembed's
// transitive ORT request away from its archived 1.21.0 pin (#2920).
// The fastembed peer is pinned as an exact version (not `catalog:`) because
// `core/fastembed-runtime.ts` reads it to `bun install` the on-demand embedding
// runtime — including from bundles where the inlined manifest would otherwise
// carry an uninstallable `catalog:` spec (#2389). The runtime cache must keep
// fastembed's own ORT dependency intact because its native addon links against
// that exact bundled library name (#3054).
describe("fastembed runtime version pins", () => {
const catalog = rootManifest.workspaces.catalog;
test("peer pins match the workspace catalog", () => {
test("fastembed peer pin matches the workspace catalog", () => {
expect(packageManifest.peerDependencies.fastembed).toBe(catalog.fastembed);
expect(packageManifest.peerDependencies["onnxruntime-node"]).toBe(catalog["onnxruntime-node"]);
});
test("pins are exact installable versions, not catalog or range specs", () => {
@@ -23,16 +21,18 @@ describe("fastembed runtime version pins", () => {
expect(packageManifest.peerDependencies["onnxruntime-node"]).toMatch(/^\d+\.\d+\.\d+$/);
});
test("runtime install overrides fastembed's transitive onnxruntime pin", () => {
test("onnxruntime peer pin matches fastembed's native ABI", () => {
expect(packageManifest.peerDependencies["onnxruntime-node"]).toBe("1.21.0");
});
test("runtime install preserves fastembed's transitive onnxruntime pin", () => {
const plan = fastembedRuntimeInstallPlan();
expect(plan.install.dependencies).toEqual({
fastembed: packageManifest.peerDependencies.fastembed,
"onnxruntime-node": packageManifest.peerDependencies["onnxruntime-node"],
});
expect(plan.install.overrides).toEqual({
"onnxruntime-common": packageManifest.peerDependencies["onnxruntime-node"],
"onnxruntime-node": packageManifest.peerDependencies["onnxruntime-node"],
});
expect(plan.versionKey).toContain("forced-ort");
expect(plan.install.overrides).toBeUndefined();
expect(plan.install.trustedDependencies).toEqual(["onnxruntime-node"]);
expect(plan.versionKey).toContain("transitive-ort");
expect(plan.versionKey).not.toContain("forced-ort");
});
});