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
8 changes: 8 additions & 0 deletions .changeset/fair-squids-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@happyvertical/smrt-core": patch
---

Prefer `@huggingface/transformers` for local embeddings and fall back to
`@xenova/transformers` only when the newer package is not installed. This
avoids the stale `sharp@0.32.x` runtime path on Node 24 while preserving
compatibility for older consumers.
1 change: 1 addition & 0 deletions packages/assets/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default defineConfig({
include: ['src/**/*.{test,spec}.ts'],
setupFiles: [smrtVitestSetupPath],
testTimeout: 30000,
hookTimeout: 30000,
fileParallelism: false,
pool: 'forks',
poolOptions: {
Expand Down
7 changes: 6 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,21 @@
"prepack": "npm run build:fresh"
},
"peerDependencies": {
"@huggingface/transformers": ">=3.0.0 <4.0.0",
"@xenova/transformers": "^2.17.0"
},
"peerDependenciesMeta": {
"@huggingface/transformers": {
"optional": true
},
"@xenova/transformers": {
"optional": true
}
},
"devDependencies": {
"@faker-js/faker": "^10.2.0",
"@types/node": "25.0.9",
"@huggingface/transformers": "^3.8.1",
"@types/node": "24.10.9",
"@types/pluralize": "^0.0.33",
"@xenova/transformers": "^2.17.2",
"vite": "7.3.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';

import { resolveLocalTransformersModule } from '../embeddings/provider';

describe('EmbeddingProvider transformer resolution', () => {
it('prefers @huggingface/transformers when both packages are available', async () => {
const importModule = vi.fn(async (moduleName: string) => ({
packageName: moduleName,
}));

const resolution = await resolveLocalTransformersModule(importModule);

expect(resolution.packageName).toBe('@huggingface/transformers');
expect(importModule).toHaveBeenCalledTimes(1);
expect(importModule).toHaveBeenCalledWith('@huggingface/transformers');
});

it('falls back to @xenova/transformers when Hugging Face transformers is not installed', async () => {
const importModule = vi.fn(async (moduleName: string) => {
if (moduleName === '@huggingface/transformers') {
throw new Error(
"Cannot find package '@huggingface/transformers' imported from test",
);
}

return { packageName: moduleName };
});

const resolution = await resolveLocalTransformersModule(importModule);

expect(resolution.packageName).toBe('@xenova/transformers');
expect(importModule).toHaveBeenNthCalledWith(
1,
'@huggingface/transformers',
);
expect(importModule).toHaveBeenNthCalledWith(2, '@xenova/transformers');
});

it('returns a helpful error when neither transformers package is installed', async () => {
const importModule = vi.fn(async (moduleName: string) => {
throw new Error(`Cannot find package '${moduleName}' imported from test`);
});

await expect(resolveLocalTransformersModule(importModule)).rejects.toThrow(
'Local embeddings require one of: @huggingface/transformers, @xenova/transformers.',
);
});

it('does not hide non-module runtime errors from the preferred package', async () => {
const importModule = vi.fn(async () => {
throw new Error('sharp native module failed to load');
});

await expect(resolveLocalTransformersModule(importModule)).rejects.toThrow(
'sharp native module failed to load',
);
});
});
84 changes: 59 additions & 25 deletions packages/core/src/embeddings/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Embedding Provider
*
* Unified interface for generating embeddings using local models or AI APIs.
* Supports @xenova/transformers for local inference and @happyvertical/ai for cloud.
* Supports transformers.js packages for local inference and @happyvertical/ai for cloud.
*/

import type { EmbeddingProviderType, ProjectEmbeddingConfig } from './types';
Expand All @@ -17,6 +17,55 @@ async function importOptional(moduleName: string): Promise<any> {
return import(/* @vite-ignore */ name);
}

export type OptionalModuleImporter = (moduleName: string) => Promise<any>;

export const LOCAL_TRANSFORMERS_PACKAGES = [
'@huggingface/transformers',
'@xenova/transformers',
] as const;

export type LocalTransformersPackage =
(typeof LOCAL_TRANSFORMERS_PACKAGES)[number];

export interface TransformersModuleResolution {
module: any;
packageName: LocalTransformersPackage;
}

function isModuleNotFoundError(error: unknown, moduleName: string): boolean {
return (
error instanceof Error &&
(error.message.includes(`Cannot find module '${moduleName}'`) ||
error.message.includes(`Cannot find package '${moduleName}'`))
);
}

function formatTransformersResolutionError(
attemptedPackages: readonly string[],
): Error {
return new Error(
`Local embeddings require one of: ${attemptedPackages.join(', ')}. ` +
`Install one of them to use provider: "local", or switch to provider: "ai".`,
);
}

export async function resolveLocalTransformersModule(
importModule: OptionalModuleImporter = importOptional,
): Promise<TransformersModuleResolution> {
for (const packageName of LOCAL_TRANSFORMERS_PACKAGES) {
try {
const module = await importModule(packageName);
return { module, packageName };
} catch (error) {
if (!isModuleNotFoundError(error, packageName)) {
throw error;
}
}
}

throw formatTransformersResolutionError(LOCAL_TRANSFORMERS_PACKAGES);
}

/**
* Interface for AI client that can generate embeddings
*/
Expand All @@ -28,7 +77,7 @@ interface EmbeddingCapableAI {
}

/**
* Pipeline type for @xenova/transformers
* Pipeline type for local transformers packages
*/
type FeatureExtractionPipeline = (
texts: string[],
Expand Down Expand Up @@ -98,7 +147,7 @@ export class EmbeddingProvider {
}

/**
* Generate embeddings using local model (@xenova/transformers)
* Generate embeddings using a local transformers model
*/
private async embedLocal(texts: string[]): Promise<number[][]> {
const pipeline = await this.getLocalPipeline();
Expand Down Expand Up @@ -133,30 +182,15 @@ export class EmbeddingProvider {
* Initialize the local embedding pipeline
*/
private async initLocalPipeline(): Promise<FeatureExtractionPipeline> {
try {
// Dynamic import for optional dependency
const transformers = await importOptional('@xenova/transformers');
const { pipeline } = transformers;
const { module: transformers } = await resolveLocalTransformersModule();
const { pipeline } = transformers;

const model = this.config.localModel || 'Xenova/bge-base-en-v1.5';
const model = this.config.localModel || 'Xenova/bge-base-en-v1.5';

// Initialize the feature extraction pipeline
const pipe = await pipeline('feature-extraction', model);
// Initialize the feature extraction pipeline
const pipe = await pipeline('feature-extraction', model);

return pipe as unknown as FeatureExtractionPipeline;
} catch (error) {
if (
error instanceof Error &&
error.message.includes("Cannot find module '@xenova/transformers'")
) {
throw new Error(
'Local embeddings require @xenova/transformers. ' +
'Install it with: pnpm add @xenova/transformers\n' +
'Or use provider: "ai" in your embedding configuration.',
);
}
throw error;
}
return pipe as unknown as FeatureExtractionPipeline;
}

/**
Expand Down Expand Up @@ -201,7 +235,7 @@ export class EmbeddingProvider {
*/
async isLocalAvailable(): Promise<boolean> {
try {
await importOptional('@xenova/transformers');
await resolveLocalTransformersModule();
return true;
} catch {
return false;
Expand Down
2 changes: 1 addition & 1 deletion packages/products/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default defineConfig({

// Timeouts for async operations
testTimeout: 30000,
hookTimeout: 10000,
hookTimeout: 30000,

// Setup files removed - file doesn't exist
// setupFiles: ['../../vitest.setup.ts'],
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

Loading