From 4c93cef4f569a299dc1537c1567fc6829f14142e Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Sat, 29 Aug 2026 11:53:56 +0200 Subject: [PATCH] ui : address review on model id parsing Use lowercase values for the sidecar enums so the value doubles as the filename token, derive the sidecar regexes from the enum values, and rename the MODEL_ID regex keys to the _REGEX suffix used by the rest of the constants files. Replace the tools capability magic string with ModelCapability.TOOL_USE. Assisted-by: pi --- .../app/models/ModelsSelectorOption.svelte | 4 +-- .../src/lib/constants/model-id.constants.ts | 36 +++++++++---------- tools/ui/src/lib/enums/model.enums.ts | 12 +++---- tools/ui/src/lib/services/models.service.ts | 20 +++++------ tools/ui/tests/unit/model-id-parser.test.ts | 8 ++--- 5 files changed, 38 insertions(+), 42 deletions(-) diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte index bc97f7370e..63b8ac6647 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte @@ -12,7 +12,7 @@ } from '@lucide/svelte'; import { ActionIcon, ModelId } from '$lib/components/app'; import { ICON_CLASS_DEFAULT } from '$lib/constants'; - import { ServerModelStatus } from '$lib/enums'; + import { ModelCapability, ServerModelStatus } from '$lib/enums'; import { modelsStore } from '$lib/stores'; import type { ModelOption } from '$lib/types/models'; import { modelLoadFraction, modelLoadProgressText } from '$lib/utils'; @@ -61,7 +61,7 @@ let modalities = $derived(option.modalities); let capabilities = $derived.by(() => ({ reasoning: modelsStore.props.checkModelSupportsThinking(option.model), - tools: option.capabilities.includes('tools') + tools: option.capabilities.includes(ModelCapability.TOOL_USE) })); diff --git a/tools/ui/src/lib/constants/model-id.constants.ts b/tools/ui/src/lib/constants/model-id.constants.ts index b4c9e50daf..9fe87d1cc4 100644 --- a/tools/ui/src/lib/constants/model-id.constants.ts +++ b/tools/ui/src/lib/constants/model-id.constants.ts @@ -7,22 +7,16 @@ import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums'; /** Any sidecar file type: a draft variant or an auxiliary sidecar like mmproj. */ export type ModelSidecar = ModelDraftSidecar | ModelAuxSidecar; -/** Lowercase filename token for each sidecar, e.g. `mtp-Q4_0.gguf`, `mmproj-F16.gguf`. */ -export const SIDECAR_FILE_TOKENS: Record = { - [ModelAuxSidecar.MMPROJ]: 'mmproj', - [ModelDraftSidecar.DFLASH]: 'dflash', - [ModelDraftSidecar.DSPARK]: 'dspark', - [ModelDraftSidecar.EAGLE3]: 'eagle3', - [ModelDraftSidecar.MTP]: 'mtp' -}; - -const SIDECARS_BY_FILE_TOKEN = Object.fromEntries( - Object.entries(SIDECAR_FILE_TOKENS).map(([sidecar, token]) => [token, sidecar]) -) as Record; +/** All sidecar filename tokens. Enum values are the lowercase filename tokens, e.g. `mtp-Q4_0.gguf`, `mmproj-F16.gguf`. */ +const SIDECAR_TOKENS: string[] = [ + ...Object.values(ModelDraftSidecar), + ...Object.values(ModelAuxSidecar) +]; +const SIDECAR_TOKEN_SET = new Set(SIDECAR_TOKENS); /** Map a lowercase filename token (e.g. `mtp`) to its sidecar enum value. */ export function sidecarFromFileToken(token: string): ModelSidecar | null { - return SIDECARS_BY_FILE_TOKEN[token] ?? null; + return SIDECAR_TOKEN_SET.has(token) ? (token as ModelSidecar) : null; } export function isDraftSidecar(sidecar: ModelSidecar): sidecar is ModelDraftSidecar { @@ -33,15 +27,17 @@ export function isAuxSidecar(sidecar: ModelSidecar): sidecar is ModelAuxSidecar return (Object.values(ModelAuxSidecar) as string[]).includes(sidecar); } +const SIDECAR_TOKEN_ALTERNATION = SIDECAR_TOKENS.join('|'); + export const MODEL_ID = { /** * Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`. * The leading `A`/`a` distinguishes it from a regular params segment. */ - ACTIVATED_PARAMS_RE: /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/, + ACTIVATED_PARAMS_REGEX: /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/, /** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */ - CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i, + CUSTOM_QUANTIZATION_PREFIX_REGEX: /^UD$/i, /** Container format segments to exclude from tags (every model uses these). */ IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']), /** Sentinel value returned by `indexOf` when a substring is not found. */ @@ -53,13 +49,13 @@ export const MODEL_ID = { * The optional leading `E` covers effective-parameter sizes, e.g. Gemma's * `E2B`/`E4B` (MatFormer models sized by resident params). */ - PARAMS_RE: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/, + PARAMS_REGEX: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/, /** * Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`. * Case-insensitive to handle both uppercase and lowercase inputs. */ - QUANTIZATION_SEGMENT_RE: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i, + QUANTIZATION_SEGMENT_REGEX: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i, /** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */ QUANTIZATION_SEPARATOR: ':', @@ -73,7 +69,7 @@ export const MODEL_ID = { * `eagle3-.gguf`, `mmproj-.gguf`. Captures the bare type * token for typed lookup. */ - SIDECAR_PREFIX_RE: /^(mtp|dflash|dspark|eagle3|mmproj)-(.*)$/i, + SIDECAR_PREFIX_REGEX: new RegExp(`^(${SIDECAR_TOKEN_ALTERNATION})-(.*)$`, 'i'), /** * Trailing `-` suffix marking a GGUF with an embedded draft in the @@ -81,8 +77,8 @@ export const MODEL_ID = { * `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. The captured prefix is the * candidate model id; the caller decides whether it looks quantized. */ - SIDECAR_SUFFIX_RE: /^(.*)-(mtp|dflash|dspark|eagle3)$/i, + SIDECAR_SUFFIX_REGEX: new RegExp(`^(.*)-(${SIDECAR_TOKEN_ALTERNATION})$`, 'i'), /** Matches a trailing weight file extension, e.g. `model.gguf` -> `model`. */ - WEIGHT_EXTENSION_RE: /\.(gguf|ggml)$/i + WEIGHT_EXTENSION_REGEX: /\.(gguf|ggml)$/i }; diff --git a/tools/ui/src/lib/enums/model.enums.ts b/tools/ui/src/lib/enums/model.enums.ts index b6b97ab2b5..b76d4a2494 100644 --- a/tools/ui/src/lib/enums/model.enums.ts +++ b/tools/ui/src/lib/enums/model.enums.ts @@ -7,7 +7,7 @@ export enum ModelModality { export enum ModelCapability { REASONING = 'REASONING', - TOOL_USE = 'TOOL_USE' + TOOL_USE = 'tools' } /** @@ -16,13 +16,13 @@ export enum ModelCapability { */ export enum ModelDraftSidecar { /** DFlash block-diffusion draft (spec-type draft-dflash). */ - DFLASH = 'DFLASH', + DFLASH = 'dflash', /** DSpark block-diffusion draft (spec-type draft-dspark). */ - DSPARK = 'DSPARK', + DSPARK = 'dspark', /** EAGLE-3 speculative draft (spec-type draft-eagle3). */ - EAGLE3 = 'EAGLE3', + EAGLE3 = 'eagle3', /** Multi-token-prediction draft head (spec-type draft-mtp). */ - MTP = 'MTP' + MTP = 'mtp' } /** @@ -31,5 +31,5 @@ export enum ModelDraftSidecar { */ export enum ModelAuxSidecar { /** Multimodal projector: unlocks vision and/or audio input modalities. */ - MMPROJ = 'MMPROJ' + MMPROJ = 'mmproj' } diff --git a/tools/ui/src/lib/services/models.service.ts b/tools/ui/src/lib/services/models.service.ts index 5bebbbb9fe..8d81d3f49a 100644 --- a/tools/ui/src/lib/services/models.service.ts +++ b/tools/ui/src/lib/services/models.service.ts @@ -127,11 +127,11 @@ export class ModelsService { // strip directory path and weight extension so a bare `-m /path/file.gguf` // parses like a clean repo id; the HF `org/model` form is preserved - let source = normalizeModelName(modelId).replace(MODEL_ID.WEIGHT_EXTENSION_RE, ''); + let source = normalizeModelName(modelId).replace(MODEL_ID.WEIGHT_EXTENSION_REGEX, ''); // 0. Detect sidecar prefix (mtp-, dflash-, mmproj-) before any other // splitting so the inner id parses cleanly. - const prefixMatch = source.match(MODEL_ID.SIDECAR_PREFIX_RE); + const prefixMatch = source.match(MODEL_ID.SIDECAR_PREFIX_REGEX); if (prefixMatch) { result.sidecar = sidecarFromFileToken(prefixMatch[1].toLowerCase()); @@ -139,7 +139,7 @@ export class ModelsService { // a sidecar filename's remainder may be just the quant token, // e.g. `mtp-Q4_0.gguf` or `mmproj-F16.gguf` - if (MODEL_ID.QUANTIZATION_SEGMENT_RE.test(source)) { + if (MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(source)) { result.quantization = source.toUpperCase(); source = ''; } @@ -148,13 +148,13 @@ export class ModelsService { // Only strip it when the segment preceding it looks like a real quant // token, so a model literally named `MyModel-mtp` is not mistaken for a // draft one. - const suffixMatch = source.match(MODEL_ID.SIDECAR_SUFFIX_RE); + const suffixMatch = source.match(MODEL_ID.SIDECAR_SUFFIX_REGEX); if (suffixMatch) { const candidate = suffixMatch[1]; const headSeg = candidate.split(MODEL_ID.SEGMENT_SEPARATOR).pop(); - if (headSeg && MODEL_ID.QUANTIZATION_SEGMENT_RE.test(headSeg)) { + if (headSeg && MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(headSeg)) { result.sidecar = sidecarFromFileToken(suffixMatch[2].toLowerCase()); source = candidate; } @@ -191,7 +191,7 @@ export class ModelsService { if (dotIdx !== MODEL_ID.NOT_FOUND && !result.quantization) { const afterDot = modelStr.slice(dotIdx + 1); - if (MODEL_ID.QUANTIZATION_SEGMENT_RE.test(afterDot)) { + if (MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(afterDot)) { result.quantization = afterDot; modelStr = modelStr.slice(0, dotIdx); } @@ -206,8 +206,8 @@ export class ModelsService { const last = segments[segments.length - 1]; const secondLast = segments.length > 2 ? segments[segments.length - 2] : null; - if (MODEL_ID.QUANTIZATION_SEGMENT_RE.test(last)) { - if (secondLast && MODEL_ID.CUSTOM_QUANTIZATION_PREFIX_RE.test(secondLast)) { + if (MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(last)) { + if (secondLast && MODEL_ID.CUSTOM_QUANTIZATION_PREFIX_REGEX.test(secondLast)) { result.quantization = `${secondLast}-${last}`; segments.splice(segments.length - 2, 2); } else { @@ -224,10 +224,10 @@ export class ModelsService { for (let i = 0; i < segments.length; i++) { const seg = segments[i]; - if (paramsIdx === MODEL_ID.NOT_FOUND && MODEL_ID.PARAMS_RE.test(seg)) { + if (paramsIdx === MODEL_ID.NOT_FOUND && MODEL_ID.PARAMS_REGEX.test(seg)) { paramsIdx = i; result.params = seg.toUpperCase(); - } else if (paramsIdx !== MODEL_ID.NOT_FOUND && MODEL_ID.ACTIVATED_PARAMS_RE.test(seg)) { + } else if (paramsIdx !== MODEL_ID.NOT_FOUND && MODEL_ID.ACTIVATED_PARAMS_REGEX.test(seg)) { activatedParamsIdx = i; result.activatedParams = seg.toUpperCase(); } diff --git a/tools/ui/tests/unit/model-id-parser.test.ts b/tools/ui/tests/unit/model-id-parser.test.ts index 82009acb9d..bf68cdec66 100644 --- a/tools/ui/tests/unit/model-id-parser.test.ts +++ b/tools/ui/tests/unit/model-id-parser.test.ts @@ -328,24 +328,24 @@ describe('parseModelId', () => { // sidecar prefix: bare filename or multi-slash path reduces to the filename expect(parseModelId('mtp-Q4_0.gguf')).toMatchObject({ quantization: 'Q4_0', - sidecar: 'MTP' + sidecar: 'mtp' }); expect(parseModelId('ggml-org/Model-GGUF/mtp-Q4_0.gguf')).toMatchObject({ quantization: 'Q4_0', - sidecar: 'MTP' + sidecar: 'mtp' }); expect(parseModelId('ggml-org/Model-GGUF/mmproj-F16.gguf')).toMatchObject({ quantization: 'F16', - sidecar: 'MMPROJ' + sidecar: 'mmproj' }); // embedded-draft suffix: - only strips when preceded by a quant expect(parseModelId('ggml-org/Hy3-IQ1_M-mtp')).toMatchObject({ modelName: 'Hy3', quantization: 'IQ1_M', - sidecar: 'MTP' + sidecar: 'mtp' }); // a model literally named MyModel-mtp is not a draft