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
This commit is contained in:
Aleksander Grygier
2026-09-01 00:22:56 +02:00
parent c1cbd5e277
commit 4c93cef4f5
5 changed files with 38 additions and 42 deletions
@@ -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)
}));
</script>
@@ -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<ModelSidecar, string> = {
[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<string, ModelSidecar>;
/** 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<string>(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-<name>.gguf`, `mmproj-<name>.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 `-<type>` 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
};
+6 -6
View File
@@ -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'
}
+10 -10
View File
@@ -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();
}
+4 -4
View File
@@ -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: -<type> 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