ggml-org/llama.cpp · #31837

ui: show audio in Discover for models whose mmproj has an audio encoder

allozaur · merged Nov 10, 202611 files · 574 + / 31 −
tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetails.svelte13 + / 7 −
@@ -35,17 +35,23 @@ 		return tags.find((t) => t.startsWith('license:'))?.replace('license:', '') ?? null; 	}); -	// Capabilities derived from HF metadata. Vision comes from an mmproj sidecar-	// or a multimodal pipeline tag; tool use / reasoning from the chat template.-	let hasMmproj = $derived(-		files.some(+	// Capabilities derived from HF metadata. Vision and audio come from the+	// pipeline tag and the mmproj; tool use / reasoning from the chat template.+	let mmprojPath = $derived(+		files.find( 			(f) => HuggingFaceService.extractQuantMeta(f.path)?.sidecar === ModelAuxSidecar.MMPROJ-		)+		)?.path+	);+	let modalities = $derived(+		HuggingFaceService.getModalities(modelId, details?.pipeline_tag, mmprojPath) 	);-	let hasVision = $derived(hasMmproj || details?.pipeline_tag === 'image-text-to-text'); 	let hasTools = $derived(detectToolUseSupport(gguf?.chat_template ?? '')); 	let hasReasoning = $derived(detectThinkingSupport(gguf?.chat_template ?? '')); +	$effect(() => {+		if (mmprojPath) void HuggingFaceService.getMmprojEncoders(modelId, mmprojPath);+	});+ 	let bitDepthRows = $derived.by<ModelBitDepthRow[]>(() => { 		const rows = new SvelteMap<number, HfModelSibling[]>(); @@ -84,8 +90,8 @@ 			{gguf} 			{hasReasoning} 			{hasTools}-			{hasVision} 			{licenseTag}+			{modalities} 			{modelId} 		/> 
tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHeader.svelte13 + / 4 −
@@ -8,6 +8,7 @@ 	import { Button } from '$lib/components/ui/button'; 	import { HuggingFaceService } from '$lib/services'; 	import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';+	import type { ModelModalities } from '$lib/types/models'; 	import { orgOf } from '$lib/utils';  	interface Props {@@ -16,13 +17,21 @@ 		gguf?: HfModelGguf; 		baseModels: string[]; 		licenseTag: string | null;-		hasVision: boolean;+		modalities: ModelModalities; 		hasTools: boolean; 		hasReasoning: boolean; 	} -	let { baseModels, details, gguf, hasReasoning, hasTools, hasVision, licenseTag, modelId }: Props =-		$props();+	let {+		baseModels,+		details,+		gguf,+		hasReasoning,+		hasTools,+		licenseTag,+		modalities,+		modelId+	}: Props = $props();  	// Avatar shows the base model's org (e.g. the Qwen logo for a ggml-org GGUF) 	// with the quant org as a corner badge when they differ.@@ -50,7 +59,7 @@ 					<ModelCapabilityIcons 						gapClass="gap-2" 						iconSize="h-4 w-4"-						modalities={{ audio: false, video: false, vision: hasVision }}+						{modalities} 						supportsThinking={hasReasoning} 						supportsToolUse={hasTools} 					/>
tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItem.svelte14 + / 14 −
@@ -5,7 +5,7 @@ 	import ModelOrgAvatar from '../../ModelOrgAvatar.svelte'; 	import { Pause, Play, X } from '@lucide/svelte'; 	import { ActionIcon } from '$lib/components/app';-	import { HF_MMPROJ_FILENAME_TOKEN, HF_MODALITY_PIPELINE_TAGS, MODEL_ID } from '$lib/constants';+	import { HF_MMPROJ_FILENAME_TOKEN, MODEL_ID } from '$lib/constants'; 	import { ModelDraftSidecar, ModelRowDownloadState } from '$lib/enums'; 	import { HuggingFaceService } from '$lib/services'; 	import { modelsDiscoverStore, modelsStore } from '$lib/stores';@@ -96,19 +96,19 @@  	let supportsToolUse = $derived(detectToolUseSupport(model.gguf?.chat_template ?? '')); -	// Modalities derived from HF metadata: vision from an mmproj sidecar or a-	// multimodal pipeline tag, audio/video from their pipeline tags.-	let modalities = $derived.by<ModelModalities>(() => {-		const tag = model.pipeline_tag ?? '';-		const vision =-			HF_MODALITY_PIPELINE_TAGS.vision.includes(tag) ||-			Boolean(-				model.siblings?.some((s) => s.rfilename.toLowerCase().includes(HF_MMPROJ_FILENAME_TOKEN))-			);-		const audio = HF_MODALITY_PIPELINE_TAGS.audio.includes(tag);-		const video = HF_MODALITY_PIPELINE_TAGS.video.includes(tag);--		return { audio, video, vision };+	let mmprojPath = $derived(+		model.siblings?.find((s) => s.rfilename.toLowerCase().includes(HF_MMPROJ_FILENAME_TOKEN))+			?.rfilename+	);++	// Modalities derived from HF metadata: the pipeline tag and the mmproj.+	let modalities = $derived<ModelModalities>(+		HuggingFaceService.getModalities(model.id, model.pipeline_tag, mmprojPath)+	);++	// the mmproj's header says whether it also hears; read once per repo+	$effect(() => {+		if (mmprojPath) void HuggingFaceService.getMmprojEncoders(model.id, mmprojPath); 	});  	// Draft sidecars (mtp, dflash, dspark, eagle3) the repo offers, badged with no
tools/ui/src/lib/constants/huggingface.constants.ts11 + / 0 −
@@ -43,6 +43,7 @@ export const HF_MODEL_LIST_EXPAND: readonly string[] = [ export const HF_MAIN_BRANCH = 'main'; export const HF_README_FILENAME = 'README.md'; export const HF_RAW_PATH = 'raw';+export const HF_RESOLVE_PATH = 'resolve'; export const HF_TREE_PATH = 'tree';  // Pagination@@ -55,7 +56,10 @@ export const HF_LINK_HEADER = 'Link';  export const HF_RETRY_ATTEMPTS = 3; export const HF_RETRY_DELAY_MS = 1000;+export const HF_HTTP_PARTIAL_CONTENT = 206; export const HF_HTTP_NOT_FOUND = 404;+/** Unauthorized, forbidden, not found: a gated, private or missing file, which a retry won't change. */+export const HF_HTTP_NO_ACCESS_STATUSES: readonly number[] = [401, 403, HF_HTTP_NOT_FOUND]; export const HF_HTTP_SERVER_ERROR_MIN = 500;  // Search limits@@ -129,6 +133,13 @@ export const HF_MODALITY_PIPELINE_TAGS: Readonly< /** Filename token marking an mmproj sidecar sibling (unlocks vision / audio). */ export const HF_MMPROJ_FILENAME_TOKEN = 'mmproj'; +/** Bytes read from the start of an mmproj to reach its metadata, which comes before the tensors. */+export const HF_MMPROJ_HEADER_BYTES = 65_536;++/** mmproj metadata keys saying which encoders it carries (tools/mtmd/mtmd-impl.h). */+export const GGUF_KEY_HAS_VISION_ENCODER = 'clip.has_vision_encoder';+export const GGUF_KEY_HAS_AUDIO_ENCODER = 'clip.has_audio_encoder';+ export const HF_TASK_TAGS: readonly string[] = [ 	'audio-classification', 	'audio-to-audio',
tools/ui/src/lib/services/huggingface.service.ts114 + / 6 −
@@ -1,4 +1,9 @@-import { PATH_SEPARATOR, SETTINGS_KEYS } from '$lib/constants';+import {+	GGUF_KEY_HAS_AUDIO_ENCODER,+	GGUF_KEY_HAS_VISION_ENCODER,+	PATH_SEPARATOR,+	SETTINGS_KEYS+} from '$lib/constants'; import { 	BYTE, 	BYTE_LABEL,@@ -24,19 +29,24 @@ import { 	HF_GATED_TAG, 	HF_GGUF_FILTER, 	HF_GGUF_TAG,+	HF_HTTP_NO_ACCESS_STATUSES, 	HF_HTTP_NOT_FOUND,+	HF_HTTP_PARTIAL_CONTENT, 	HF_HTTP_SERVER_ERROR_MIN, 	HF_LICENSE_TAG_PREFIX, 	HF_LINK_HEADER, 	HF_LINK_NEXT_REGEX, 	HF_MAIN_BRANCH, 	HF_MAX_LIMIT,+	HF_MMPROJ_HEADER_BYTES,+	HF_MODALITY_PIPELINE_TAGS, 	HF_MODEL_LIST_EXPAND, 	HF_PARAM_COUNT_REGEX, 	HF_QUANT_PRECISION_REGEX, 	HF_RAW_PATH, 	HF_README_FILENAME, 	HF_RECURSIVE_TREE_PARAM,+	HF_RESOLVE_PATH, 	HF_RETRY_ATTEMPTS, 	HF_RETRY_DELAY_MS, 	HF_SAFETENSORS_TAG,@@ -69,12 +79,14 @@ import { HfEntryType, HfModelSort, SidecarForm } from '$lib/enums'; import { settingsStore } from '$lib/stores/settings/index.svelte'; import type { 	HfCatalogEntry,+	HfMmprojEncoders, 	HfModelDetailInfo, 	HfModelInfo, 	HfModelSearchParams, 	HfModelSibling } from '$lib/types/huggingface';-import { sidecarFromFileToken } from '$lib/utils';+import type { ModelModalities } from '$lib/types/models';+import { readGgufMetadata, sidecarFromFileToken } from '$lib/utils'; import { SvelteMap } from 'svelte/reactivity';  /** Fetch failure carrying the HTTP status, so retry logic tests the code instead of the message. */@@ -107,6 +119,11 @@ export class HuggingFaceService {  	private static detailsPending = new Map<string, Promise<HfModelDetailInfo | null>>(); +	// Encoders an mmproj carries, keyed by repo id: its header never changes, so one read per session.+	private static mmprojEncodersCache = new SvelteMap<string, HfMmprojEncoders>();++	private static mmprojEncodersPending = new Map<string, Promise<HfMmprojEncoders | null>>();+ 	/** 	 * Map of quant token to its average bit-depth in bits-per-weight (bpw). 	 */@@ -160,8 +177,6 @@ export class HuggingFaceService { 		return HuggingFaceService.detailsCache.get(hfRepoId); 	} -	// GGUF Model Browsing- 	/** 	 * Collapse split GGUF shard sets (`-00001-of-00015.gguf`, ...) to their first 	 * shard, summing every shard's size so the kept entry reflects the whole@@ -200,6 +215,8 @@ export class HuggingFaceService { 		return result; 	} +	// GGUF Model Browsing+ 	/** 	 * Extract the GGUF quantization token (e.g. `Q4_K_M`) and any sidecar type 	 * (`mtp`, `dflash`, `mmproj`, ...) from a `.gguf` filename.@@ -355,8 +372,6 @@ export class HuggingFaceService { 		return `${Math.floor(diffDays / DAYS_PER_YEAR)} ${YEARS_AGO_LABEL}`; 	} -	// Model Details & Files- 	/** 	 * Format a min-max size range with one shared unit, e.g. `19.0-28.6 GB`. 	 */@@ -382,6 +397,8 @@ export class HuggingFaceService { 		return `${fmt(min)}-${fmt(max)} ${unit}`; 	} +	// Model Details & Files+ 	/** 	 * Avatar URL for an author (org or user). 404s when the author does not 	 * exist, so callers should provide a fallback.@@ -544,6 +561,97 @@ export class HuggingFaceService { 		return promise; 	} +	/**+	 * Which encoders a repo's mmproj carries, from the metadata at the start of the file: a ranged read of its+	 * first bytes, so the file itself is never downloaded. The pipeline tag can't say this: omni models are+	 * `any-to-any`, and their audio encoder lives in the same mmproj as the vision one. Null when the read fails.+	 */+	static getMmprojEncoders(modelId: string, mmprojPath: string): Promise<HfMmprojEncoders | null> {+		if (!HuggingFaceService.isEnabled()) return Promise.resolve(null);++		const cached = HuggingFaceService.mmprojEncodersCache.get(modelId);++		if (cached) return Promise.resolve(cached);++		const pending = HuggingFaceService.mmprojEncodersPending.get(modelId);++		if (pending) return pending;++		// Do not encode the modelId, it contains slashes for author/name+		const url = `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_RESOLVE_PATH}${PATH_SEPARATOR}${HF_MAIN_BRANCH}${PATH_SEPARATOR}${mmprojPath.split(PATH_SEPARATOR).map(encodeURIComponent).join(PATH_SEPARATOR)}`;+		const promise = (async () => {+			const response = await fetch(url, {+				headers: { Range: `bytes=0-${HF_MMPROJ_HEADER_BYTES - 1}` }+			});++			// anything but a partial response would stream the whole file, which can be gigabytes+			if (response.status !== HF_HTTP_PARTIAL_CONTENT) {+				void response.body?.cancel();++				// a gated, private or missing file answers the same on every read: keep what the repo says+				if (HF_HTTP_NO_ACCESS_STATUSES.includes(response.status)) {+					const unknown: HfMmprojEncoders = { audio: false, vision: null };++					HuggingFaceService.mmprojEncodersCache.set(modelId, unknown);++					return unknown;+				}++				throw new Error(`Failed to read ${mmprojPath}: ${response.status}`);+			}++			const { complete, metadata } = readGgufMetadata(await response.arrayBuffer());++			if (metadata.size === 0) throw new Error(`${mmprojPath} has no GGUF metadata`);++			// the converter writes these keys only when true, so one missing from a header read in full means no+			// such encoder; a header cut short leaves vision unknown+			const hasVision = metadata.get(GGUF_KEY_HAS_VISION_ENCODER) === true;+			const encoders: HfMmprojEncoders = {+				audio: metadata.get(GGUF_KEY_HAS_AUDIO_ENCODER) === true,+				vision: hasVision || (complete ? false : null)+			};++			HuggingFaceService.mmprojEncodersCache.set(modelId, encoders);++			return encoders;+		})()+			.catch((error: unknown) => {+				// not cached: a rate limited or failed read should retry on the next mount+				console.error(`Error reading the mmproj of ${modelId}:`, error);++				return null;+			})+			.finally(() => HuggingFaceService.mmprojEncodersPending.delete(modelId));++		HuggingFaceService.mmprojEncodersPending.set(modelId, promise);++		return promise;+	}++	/**+	 * Modalities a repo offers, from its pipeline tag and its mmproj: vision from a vision tag or an mmproj+	 * (unless its header has no vision encoder, as in audio-only models), audio from an audio tag or+	 * an audio encoder in the mmproj (omni models are `any-to-any`), video from a video tag. Reactive: it+	 * updates once {@link getMmprojEncoders} has read the mmproj.+	 */+	static getModalities(+		modelId: string,+		pipelineTag: string | null | undefined,+		mmprojPath: string | undefined+	): ModelModalities {+		const tag = pipelineTag ?? '';+		const encoders = mmprojPath ? HuggingFaceService.mmprojEncodersCache.get(modelId) : undefined;++		return {+			audio: HF_MODALITY_PIPELINE_TAGS.audio.includes(tag) || encoders?.audio === true,+			video: HF_MODALITY_PIPELINE_TAGS.video.includes(tag),+			vision:+				HF_MODALITY_PIPELINE_TAGS.vision.includes(tag) ||+				(Boolean(mmprojPath) && encoders?.vision !== false)+		};+	}+ 	static getModelUrl(modelId: string): string { 		return `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}`; 	}
tools/ui/src/lib/types/huggingface.d.ts6 + / 0 −
@@ -172,6 +172,12 @@ export interface HfModelDetailInfo extends HfModelInfo { }  /** A single entry in a model repository's file tree (`/tree` responses) */+/** Encoders an mmproj's header says it carries; vision is null when the header was cut short before it said. */+export interface HfMmprojEncoders {+	audio: boolean;+	vision: boolean | null;+}+ export interface HfModelSibling { 	/** Relative path of the file or directory within the repo */ 	path: string;
tools/ui/src/lib/types/index.ts1 + / 0 −
@@ -47,6 +47,7 @@ export type { 	HfModelGguf, 	HfModelInfo, 	HfModelSearchParams,+	HfMmprojEncoders, 	HfModelSibling, 	HfModelSiblingRef } from './huggingface';
tools/ui/src/lib/utils/gguf-metadata.tsadded153 + / 0 −
@@ -0,0 +1,153 @@+/**+ * Reads the metadata key/value pairs at the start of a GGUF file, from as many bytes of it as are given.+ * Format: https://github.com/ggml-org/ggml/blob/master/docs/gguf.md+ */++const GGUF_MAGIC = 0x46554747; // "GGUF", little-endian++enum GgufType {+	UINT8 = 0,+	INT8 = 1,+	UINT16 = 2,+	INT16 = 3,+	UINT32 = 4,+	INT32 = 5,+	FLOAT32 = 6,+	BOOL = 7,+	STRING = 8,+	ARRAY = 9,+	UINT64 = 10,+	INT64 = 11,+	FLOAT64 = 12+}++const SCALAR_SIZE: Partial<Record<GgufType, number>> = {+	[GgufType.BOOL]: 1,+	[GgufType.FLOAT32]: 4,+	[GgufType.FLOAT64]: 8,+	[GgufType.INT8]: 1,+	[GgufType.INT16]: 2,+	[GgufType.INT32]: 4,+	[GgufType.INT64]: 8,+	[GgufType.UINT8]: 1,+	[GgufType.UINT16]: 2,+	[GgufType.UINT32]: 4,+	[GgufType.UINT64]: 8+};++export type GgufValue = boolean | number | bigint | string;++export interface GgufMetadata {+	/** True when every pair was read; false when the buffer ended first or is not GGUF. */+	complete: boolean;+	metadata: Map<string, GgufValue>;+}++/**+ * The scalar and string metadata of a GGUF file (arrays are skipped). A buffer cut short returns the pairs read+ * before the cut; a buffer that is not GGUF returns an empty map.+ */+export function readGgufMetadata(buffer: ArrayBuffer): GgufMetadata {+	const view = new DataView(buffer);+	const decoder = new TextDecoder();+	const metadata = new Map<string, GgufValue>();++	let offset = 0;++	const fits = (bytes: number) => offset + bytes <= view.byteLength;+	const readString = (): string | null => {+		if (!fits(8)) return null;++		const length = Number(view.getBigUint64(offset, true));++		if (!fits(8 + length)) return null;++		const text = decoder.decode(new Uint8Array(buffer, offset + 8, length));++		offset += 8 + length;++		return text;+	};+	const readScalar = (type: GgufType): GgufValue | null => {+		const size = SCALAR_SIZE[type];++		if (size === undefined || !fits(size)) return null;++		const at = offset;++		offset += size;++		switch (type) {+			case GgufType.UINT8:+				return view.getUint8(at);+			case GgufType.INT8:+				return view.getInt8(at);+			case GgufType.UINT16:+				return view.getUint16(at, true);+			case GgufType.INT16:+				return view.getInt16(at, true);+			case GgufType.UINT32:+				return view.getUint32(at, true);+			case GgufType.INT32:+				return view.getInt32(at, true);+			case GgufType.FLOAT32:+				return view.getFloat32(at, true);+			case GgufType.BOOL:+				return view.getUint8(at) !== 0;+			case GgufType.UINT64:+				return view.getBigUint64(at, true);+			case GgufType.INT64:+				return view.getBigInt64(at, true);+			default:+				return view.getFloat64(at, true);+		}+	};+	// moves past an array's elements; false when the buffer ends inside it+	const skipArray = (): boolean => {+		if (!fits(12)) return false;++		const type = view.getUint32(offset, true) as GgufType;+		const count = Number(view.getBigUint64(offset + 4, true));++		offset += 12;++		for (let i = 0; i < count; i++) {+			const value = type === GgufType.STRING ? readString() : readScalar(type);++			if (value === null) return false;+		}++		return true;+	};++	// magic, version, tensor count, metadata count+	if (!fits(24) || view.getUint32(0, true) !== GGUF_MAGIC) return { complete: false, metadata };++	const count = Number(view.getBigUint64(16, true));++	offset = 24;++	for (let i = 0; i < count; i++) {+		const key = readString();++		if (key === null || !fits(4)) return { complete: false, metadata };++		const type = view.getUint32(offset, true) as GgufType;++		offset += 4;++		if (type === GgufType.ARRAY) {+			if (!skipArray()) return { complete: false, metadata };++			continue;+		}++		const value = type === GgufType.STRING ? readString() : readScalar(type);++		if (value === null) return { complete: false, metadata };++		metadata.set(key, value);+	}++	return { complete: true, metadata };+}
tools/ui/src/lib/utils/index.ts3 + / 0 −
@@ -366,6 +366,9 @@ export { detectToolUseSupport } from './chat-template-tool-detector'; // Model memory estimation export { minMemoryTierGb } from './model-compatibility'; +// GGUF file metadata, e.g. which encoders an mmproj carries+export { type GgufMetadata, type GgufValue, readGgufMetadata } from './gguf-metadata';+ // Cryptography utilities  export { uuid } from './uuid';
tools/ui/tests/unit/gguf-metadata.test.tsadded98 + / 0 −
@@ -0,0 +1,98 @@+import { readGgufMetadata } from '$lib/utils/gguf-metadata';+import { describe, expect, it } from 'vitest';++type Entry =+	| [key: string, type: 'bool', value: boolean]+	| [key: string, type: 'u32', value: number]+	| [key: string, type: 'string', value: string]+	| [key: string, type: 'string[]', value: string[]]+	| [key: string, type: 'f32[]', value: number[]];++/** A GGUF header with the given metadata and no tensors. */+function gguf(entries: Entry[]): ArrayBuffer {+	const bytes: number[] = [];+	const u32 = (n: number) => bytes.push(...new Uint8Array(new Uint32Array([n]).buffer));+	const u64 = (n: number) => bytes.push(...new Uint8Array(new BigUint64Array([BigInt(n)]).buffer));+	const str = (s: string) => {+		const encoded = new TextEncoder().encode(s);++		u64(encoded.length);+		bytes.push(...encoded);+	};++	u32(0x46554747);+	u32(3);+	u64(0);+	u64(entries.length);++	for (const [key, type, value] of entries) {+		str(key);++		if (type === 'bool') {+			u32(7);+			bytes.push(value ? 1 : 0);+		} else if (type === 'u32') {+			u32(4);+			u32(value);+		} else if (type === 'string') {+			u32(8);+			str(value);+		} else if (type === 'string[]') {+			u32(9);+			u32(8);+			u64(value.length);+			value.forEach(str);+		} else {+			u32(9);+			u32(6);+			u64(value.length);+			value.forEach((n) => bytes.push(...new Uint8Array(new Float32Array([n]).buffer)));+		}+	}++	return new Uint8Array(bytes).buffer;+}++const MMPROJ: Entry[] = [+	['general.architecture', 'string', 'clip'],+	['clip.projector_type', 'string', 'gemma4'],+	['clip.vision.image_mean', 'f32[]', [0.5, 0.5, 0.5]],+	['tokenizer.ggml.tokens', 'string[]', ['<a>', '<b>']],+	['clip.has_vision_encoder', 'bool', true],+	['clip.has_audio_encoder', 'bool', true],+	['clip.audio.num_mel_bins', 'u32', 128]+];++describe('readGgufMetadata', () => {+	it('reads scalars and strings, skipping arrays', () => {+		const { complete, metadata } = readGgufMetadata(gguf(MMPROJ));++		expect(complete).toBe(true);++		expect(metadata.get('general.architecture')).toBe('clip');+		expect(metadata.get('clip.has_vision_encoder')).toBe(true);+		expect(metadata.get('clip.has_audio_encoder')).toBe(true);+		expect(metadata.get('clip.audio.num_mel_bins')).toBe(128);+		expect(metadata.has('clip.vision.image_mean')).toBe(false);+	});++	it('returns what it read before a buffer cut short', () => {+		const full = gguf(MMPROJ);+		const { complete, metadata } = readGgufMetadata(full.slice(0, full.byteLength - 20));++		expect(complete).toBe(false);++		expect(metadata.get('clip.has_vision_encoder')).toBe(true);+		expect(metadata.has('clip.audio.num_mel_bins')).toBe(false);+	});++	it('returns nothing for a file that is not GGUF', () => {+		expect(+			readGgufMetadata(new TextEncoder().encode('not a gguf file at all...').buffer)+		).toStrictEqual({ complete: false, metadata: new Map() });+		expect(readGgufMetadata(new ArrayBuffer(0))).toStrictEqual({+			complete: false,+			metadata: new Map()+		});+	});+});
tools/ui/tests/unit/hf-mmproj-modalities.test.tsadded148 + / 0 −
@@ -0,0 +1,148 @@+import { HuggingFaceService } from '$lib/services/huggingface.service';+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';++/** A GGUF header with the given bool metadata and no tensors. */+function mmprojHeader(entries: Record<string, boolean>): ArrayBuffer {+	const bytes: number[] = [];+	const u32 = (n: number) => bytes.push(...new Uint8Array(new Uint32Array([n]).buffer));+	const u64 = (n: number) => bytes.push(...new Uint8Array(new BigUint64Array([BigInt(n)]).buffer));++	u32(0x46554747);+	u32(3);+	u64(0);+	u64(Object.keys(entries).length);++	for (const [key, value] of Object.entries(entries)) {+		const encoded = new TextEncoder().encode(key);++		u64(encoded.length);+		bytes.push(...encoded);+		u32(7);+		bytes.push(value ? 1 : 0);+	}++	return new Uint8Array(bytes).buffer;+}++function respond(body: ArrayBuffer, status: number) {+	const fetchMock = vi.fn(async () => new Response(body, { status }));++	vi.stubGlobal('fetch', fetchMock);++	return fetchMock;+}++describe('HuggingFaceService mmproj modalities', () => {+	beforeEach(() => {+		vi.spyOn(HuggingFaceService, 'isEnabled').mockReturnValue(true);+		vi.spyOn(console, 'error').mockImplementation(() => {});+	});++	afterEach(() => {+		vi.restoreAllMocks();+		vi.unstubAllGlobals();+	});++	it('takes audio from the pipeline tag and vision from the mmproj before its header is read', () => {+		expect(HuggingFaceService.getModalities('org/unread', 'text-to-speech', 'mmproj.gguf')).toEqual(+			{ audio: true, video: false, vision: true }+		);+		expect(HuggingFaceService.getModalities('org/plain', 'text-generation', undefined)).toEqual({+			audio: false,+			video: false,+			vision: false+		});+	});++	it('adds audio for an omni mmproj, with a ranged read of an encoded path', async () => {+		const fetchMock = respond(+			mmprojHeader({ 'clip.has_audio_encoder': true, 'clip.has_vision_encoder': true }),+			206+		);++		await HuggingFaceService.getMmprojEncoders('org/omni', 'sub dir/mmproj#1.gguf');++		expect(fetchMock).toHaveBeenCalledWith(+			'https://huggingface.co/org/omni/resolve/main/sub%20dir/mmproj%231.gguf',+			{ headers: { Range: 'bytes=0-65535' } }+		);+		expect(HuggingFaceService.getModalities('org/omni', 'any-to-any', 'mmproj.gguf')).toEqual({+			audio: true,+			video: false,+			vision: true+		});+	});++	it('drops vision for an audio-only mmproj', async () => {+		// the converter writes the vision key only when true+		respond(mmprojHeader({ 'clip.has_audio_encoder': true }), 206);++		await HuggingFaceService.getMmprojEncoders('org/audio-only', 'mmproj.gguf');++		expect(HuggingFaceService.getModalities('org/audio-only', undefined, 'mmproj.gguf')).toEqual({+			audio: true,+			video: false,+			vision: false+		});+	});++	it('keeps vision when a header cut short never said', async () => {+		const header = mmprojHeader({+			'clip.has_audio_encoder': true,+			'clip.has_vision_encoder': true+		});++		respond(header.slice(0, header.byteLength - 4), 206);++		expect(await HuggingFaceService.getMmprojEncoders('org/cut', 'mmproj.gguf')).toEqual({+			audio: true,+			vision: null+		});+		expect(HuggingFaceService.getModalities('org/cut', undefined, 'mmproj.gguf').vision).toBe(true);+	});++	it('keeps vision only for a vision mmproj', async () => {+		respond(mmprojHeader({ 'clip.has_vision_encoder': true }), 206);++		await HuggingFaceService.getMmprojEncoders('org/vision', 'mmproj.gguf');++		expect(HuggingFaceService.getModalities('org/vision', 'image-text-to-text', 'm.gguf')).toEqual({+			audio: false,+			video: false,+			vision: true+		});+	});++	it('keeps what the repo says for a gated mmproj, without reading it again', async () => {+		const fetchMock = respond(new ArrayBuffer(0), 401);++		expect(await HuggingFaceService.getMmprojEncoders('org/gated', 'mmproj.gguf')).toEqual({+			audio: false,+			vision: null+		});+		await HuggingFaceService.getMmprojEncoders('org/gated', 'mmproj.gguf');++		expect(fetchMock).toHaveBeenCalledTimes(1);+		expect(HuggingFaceService.getModalities('org/gated', undefined, 'mmproj.gguf')).toEqual({+			audio: false,+			video: false,+			vision: true+		});+	});++	it('rejects a full response that ignored the range, and retries on the next call', async () => {+		respond(mmprojHeader({ 'clip.has_audio_encoder': true }), 200);++		expect(await HuggingFaceService.getMmprojEncoders('org/no-range', 'mmproj.gguf')).toBeNull();+		expect(HuggingFaceService.getModalities('org/no-range', undefined, 'mmproj.gguf').audio).toBe(+			false+		);++		respond(mmprojHeader({ 'clip.has_audio_encoder': true }), 206);++		expect(await HuggingFaceService.getMmprojEncoders('org/no-range', 'mmproj.gguf')).toEqual({+			audio: true,+			vision: false+		});+	});+});