From c9e4fe97eb7567bfcb9daa4b5ce6e2131b2bb245 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Tue, 29 Sep 2026 13:42:49 +0200 Subject: [PATCH] ui : harden the HF data layer error typing, cover the helpers in tests Carries the HTTP status on retryable fetch errors instead of matching the message text. Marks expand-dependent catalog fields optional and documents the data/models index pairing. Adds table tests for the pure helpers. Assisted-by: pi:zai-org/GLM-5.3-Flash --- .../src/lib/services/huggingface.service.ts | 15 +- tools/ui/src/lib/types/huggingface.d.ts | 12 +- tools/ui/tests/unit/hf-helpers.test.ts | 128 ++++++++++++++++++ 3 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 tools/ui/tests/unit/hf-helpers.test.ts diff --git a/tools/ui/src/lib/services/huggingface.service.ts b/tools/ui/src/lib/services/huggingface.service.ts index 5001191b33..346da82b5c 100644 --- a/tools/ui/src/lib/services/huggingface.service.ts +++ b/tools/ui/src/lib/services/huggingface.service.ts @@ -73,6 +73,17 @@ import type { } from '$lib/types/huggingface'; import { sidecarFromFileToken } from '$lib/utils'; +/** Fetch failure carrying the HTTP status, so retry logic tests the code instead of the message. */ +class HfHttpStatusError extends Error { + status: number; + + constructor(status: number, statusText: string) { + super(`API request failed: ${status} ${statusText}`); + + this.status = status; + } +} + export class HuggingFaceService { private static readonly BASE_URL = HF_API_MODELS_URL; @@ -674,7 +685,7 @@ export class HuggingFaceService { return this.fetchWithRetry(url, attempt + 1); } - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + throw new HfHttpStatusError(response.status, response.statusText); } const data = await response.json(); @@ -691,7 +702,7 @@ export class HuggingFaceService { } catch (error) { const transient = error instanceof TypeError || - (error instanceof Error && error.message.startsWith('API request failed: 5')); + (error instanceof HfHttpStatusError && error.status >= HF_HTTP_SERVER_ERROR_MIN); if (transient && attempt < HF_RETRY_ATTEMPTS) { await this.delay(HF_RETRY_DELAY_MS * attempt); diff --git a/tools/ui/src/lib/types/huggingface.d.ts b/tools/ui/src/lib/types/huggingface.d.ts index c4a6b9300c..675c86b330 100644 --- a/tools/ui/src/lib/types/huggingface.d.ts +++ b/tools/ui/src/lib/types/huggingface.d.ts @@ -5,6 +5,8 @@ * Reference: https://huggingface.co/docs/huggingface_hub/package_reference/hf_api */ +import type { HfEntryType, HfModelSort } from '$lib/enums'; + // Search Options export interface HfModelSearchParams { @@ -40,8 +42,6 @@ export interface HfModelSearchParams { gated?: boolean; } -import type { HfEntryType, HfModelSort } from '$lib/enums'; - // Model Info (from /api/models) export interface HfModelInfo { @@ -51,8 +51,8 @@ export interface HfModelInfo { id: string; /** Number of likes */ likes: number; - /** Trending score */ - trendingScore: number; + /** Trending score; only present when the query sorts by it or expands the field */ + trendingScore?: number; /** Whether the model is private */ private: boolean; /** Number of downloads */ @@ -63,8 +63,8 @@ export interface HfModelInfo { pipeline_tag: string | null; /** Library name (e.g., "transformers", "diffusers") */ library_name: string | null; - /** Creation timestamp */ - createdAt: string; + /** Creation timestamp; only present when the query expands the field */ + createdAt?: string; /** Model ID (alias for id) */ modelId: string; /** Author / organization (present when full=true) */ diff --git a/tools/ui/tests/unit/hf-helpers.test.ts b/tools/ui/tests/unit/hf-helpers.test.ts new file mode 100644 index 0000000000..d73a8de22e --- /dev/null +++ b/tools/ui/tests/unit/hf-helpers.test.ts @@ -0,0 +1,128 @@ +import { HuggingFaceService } from '$lib/services/huggingface.service'; +import { describe, expect, it } from 'vitest'; + +const { + collapseGgufShards, + formatSizeRange, + getBitDepth, + parseCachePath, + parseParamCount, + parseSizeBytes +} = HuggingFaceService; + +describe('collapseGgufShards', () => { + it('passes non-sharded files through', () => { + const files = [ + { path: 'Model-Q4_K_M.gguf', size: 100 }, + { path: 'mmproj-F16.gguf', size: 10 } + ]; + + expect(collapseGgufShards(files)).toStrictEqual(files); + }); + + it('collapses a shard set to its first shard with the summed size', () => { + const files = [ + { path: 'Model-00001-of-00003.gguf', size: 10 }, + { path: 'Model-00002-of-00003.gguf', size: 20 }, + { path: 'Model-00003-of-00003.gguf', size: 30 } + ]; + + expect(collapseGgufShards(files)).toStrictEqual([ + { path: 'Model-00001-of-00003.gguf', size: 60 } + ]); + }); + + it('treats a missing shard as zero bytes', () => { + const files = [ + { path: 'Model-00001-of-00002.gguf', size: 10 }, + { path: 'Model-Q8_0.gguf', size: 5 } + ]; + + expect(collapseGgufShards(files)).toStrictEqual([ + { path: 'Model-00001-of-00002.gguf', size: 10 }, + { path: 'Model-Q8_0.gguf', size: 5 } + ]); + }); +}); + +describe('getBitDepth', () => { + it('resolves known tokens', () => { + expect(getBitDepth('Q4_K_M')).toBe(4); + expect(getBitDepth('BF16')).toBe(16); + expect(getBitDepth('IQ2_XXS')).toBe(2); + }); + + it('strips the UD prefix', () => { + expect(getBitDepth('UD-Q4_K_XL')).toBe(4); + }); + + it('falls back to the leading precision digits', () => { + expect(getBitDepth('TQ1_0')).toBe(1); + expect(getBitDepth('MXFP4_MOE')).toBe(4); + }); + + it('returns null for unrecognized tokens', () => { + expect(getBitDepth('xyz')).toBeNull(); + expect(getBitDepth('QUANT')).toBeNull(); + }); +}); + +describe('formatSizeRange', () => { + it('formats a gigabyte range without spaces around the dash', () => { + expect(formatSizeRange(19e9, 28.6e9)).toBe('19.0-28.6 GB'); + }); + + it('downgrades the unit to the smaller bound when the max is small', () => { + expect(formatSizeRange(1e6, 2.5e6)).toBe('1.0-2.5 MB'); + }); + + it('formats sub-kilobyte sizes in bytes', () => { + expect(formatSizeRange(100, 900)).toBe('100-900 B'); + }); +}); + +describe('parseCachePath', () => { + it('parses a posix cache path into repo and file', () => { + expect( + parseCachePath( + '/home/u/.cache/llama.cpp/models--ggml-org--Qwen3-8B-GGUF/snapshots/abc123/Q4_K_M.gguf' + ) + ).toStrictEqual({ file: 'Q4_K_M.gguf', repo: 'ggml-org/Qwen3-8B-GGUF' }); + }); + + it('accepts windows separators', () => { + expect( + parseCachePath('C:\\cache\\models--org--Model\\snapshots\\sha\\sub\\file.gguf') + ).toStrictEqual({ file: 'sub/file.gguf', repo: 'org/Model' }); + }); + + it('returns null for non-cache paths', () => { + expect(parseCachePath('/models/foo.gguf')).toBeNull(); + }); +}); + +describe('parseParamCount', () => { + it('extracts billions and millions', () => { + expect(parseParamCount('Qwen3.8-27B-GGUF')).toBe('27B'); + expect(parseParamCount('embeddinggemma-300M-GGUF')).toBe('300M'); + expect(parseParamCount('Model-0.6B-Q4_K_M')).toBe('0.6B'); + }); + + it('returns null when no size token is present', () => { + expect(parseParamCount('ggml-org/Laguna-S-GGUF')).toBeNull(); + }); +}); + +describe('parseSizeBytes', () => { + it('parses single-letter catalog size suffixes', () => { + expect(parseSizeBytes('177g')).toBe(177e9); + expect(parseSizeBytes('1.2 t')).toBe(1.2e12); + expect(parseSizeBytes('500m')).toBe(500e6); + }); + + it('returns null for malformed input', () => { + expect(parseSizeBytes('unknown')).toBeNull(); + expect(parseSizeBytes('12 parsecs')).toBeNull(); + expect(parseSizeBytes('')).toBeNull(); + }); +});