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
This commit is contained in:
Aleksander Grygier
2026-09-29 13:55:02 +02:00
parent d7dfc53c16
commit c9e4fe97eb
3 changed files with 147 additions and 8 deletions
@@ -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);
+6 -6
View File
@@ -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) */
+128
View File
@@ -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();
});
});