From 156ec3c3636fd7ccaa633d314d034dbf110a8198 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 19 Jun 2026 15:14:38 +0000 Subject: [PATCH] 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 --- bun.lock | 2 +- packages/mnemopi/CHANGELOG.md | 4 ++ packages/mnemopi/package.json | 2 +- packages/mnemopi/src/core/embeddings.ts | 14 ++++- .../mnemopi/src/core/fastembed-model-cache.ts | 34 +++++++++++ .../mnemopi/src/core/fastembed-runtime.ts | 26 ++++---- .../test/fastembed-model-cache.test.ts | 61 +++++++++++++++++++ .../mnemopi/test/fastembed-runtime.test.ts | 32 +++++----- 8 files changed, 143 insertions(+), 32 deletions(-) create mode 100644 packages/mnemopi/src/core/fastembed-model-cache.ts create mode 100644 packages/mnemopi/test/fastembed-model-cache.test.ts diff --git a/bun.lock b/bun.lock index ee1e83249..ac443166c 100644 --- a/bun.lock +++ b/bun.lock @@ -165,7 +165,7 @@ }, "peerDependencies": { "fastembed": "2.1.0", - "onnxruntime-node": "1.26.0", + "onnxruntime-node": "1.21.0", }, "optionalPeers": [ "fastembed", diff --git a/packages/mnemopi/CHANGELOG.md b/packages/mnemopi/CHANGELOG.md index e0b81b92a..203912c2c 100644 --- a/packages/mnemopi/CHANGELOG.md +++ b/packages/mnemopi/CHANGELOG.md @@ -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 diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index 80c88b72e..add95fc4b 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -46,7 +46,7 @@ }, "peerDependencies": { "fastembed": "2.1.0", - "onnxruntime-node": "1.26.0" + "onnxruntime-node": "1.21.0" }, "peerDependenciesMeta": { "fastembed": { diff --git a/packages/mnemopi/src/core/embeddings.ts b/packages/mnemopi/src/core/embeddings.ts index fd80acd78..241122364 100644 --- a/packages/mnemopi/src/core/embeddings.ts +++ b/packages/mnemopi/src/core/embeddings.ts @@ -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 { 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() { diff --git a/packages/mnemopi/src/core/fastembed-model-cache.ts b/packages/mnemopi/src/core/fastembed-model-cache.ts new file mode 100644 index 000000000..e88cf70a7 --- /dev/null +++ b/packages/mnemopi/src/core/fastembed-model-cache.ts @@ -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 = { + "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 { + 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; +} diff --git a/packages/mnemopi/src/core/fastembed-runtime.ts b/packages/mnemopi/src/core/fastembed-runtime.ts index f04cb5593..3a8978fed 100644 --- a/packages/mnemopi/src/core/fastembed-runtime.ts +++ b/packages/mnemopi/src/core/fastembed-runtime.ts @@ -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"], }, }; } diff --git a/packages/mnemopi/test/fastembed-model-cache.test.ts b/packages/mnemopi/test/fastembed-model-cache.test.ts new file mode 100644 index 000000000..890ea21dc --- /dev/null +++ b/packages/mnemopi/test/fastembed-model-cache.test.ts @@ -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(); + } + }); +}); diff --git a/packages/mnemopi/test/fastembed-runtime.test.ts b/packages/mnemopi/test/fastembed-runtime.test.ts index 7bab8ace5..7b123cb72 100644 --- a/packages/mnemopi/test/fastembed-runtime.test.ts +++ b/packages/mnemopi/test/fastembed-runtime.test.ts @@ -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"); }); });