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:
@@ -165,7 +165,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastembed": "2.1.0",
|
||||
"onnxruntime-node": "1.26.0",
|
||||
"onnxruntime-node": "1.21.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"fastembed",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastembed": "2.1.0",
|
||||
"onnxruntime-node": "1.26.0"
|
||||
"onnxruntime-node": "1.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"fastembed": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user