Commit f65325040 for llama.cpp

commit f65325040723e67c6d87cfd14c5389982bc313c4
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Wed Sep 30 12:42:16 2026 +0200

    ui : model id grammar for sidecars, quants and capability parsing (#27946)

    * ui : model id grammar for sidecars, quants and capability parsing

    Extend the shared model id parser with sidecar tokens (draft variants and
    auxiliary imatrix/mmproj files), weight-file and custom-quant regexes, and add
    the tools capability to ModelCapabilities; the selector option row picks it up
    from the model's declared capabilities.

    Assisted-by: pi:GLM-5.3-Flash

    * ui : escape sidecar tokens in the regex alternation

    Assisted-by: pi:zai-org/GLM-5.3-Flash

diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
index 1faeb0c4a..63b8ac664 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';
@@ -60,7 +60,8 @@
 	let loadTitle = $derived(modelLoadProgressText(loadProgress));
 	let modalities = $derived(option.modalities);
 	let capabilities = $derived.by(() => ({
-		reasoning: modelsStore.props.checkModelSupportsThinking(option.model)
+		reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
+		tools: option.capabilities.includes(ModelCapability.TOOL_USE)
 	}));
 </script>

diff --git a/tools/ui/src/lib/constants/icons.constants.ts b/tools/ui/src/lib/constants/icons.constants.ts
index a0ce22f95..ce32f0e1e 100644
--- a/tools/ui/src/lib/constants/icons.constants.ts
+++ b/tools/ui/src/lib/constants/icons.constants.ts
@@ -10,7 +10,8 @@ import {
 	Image as ImageIcon,
 	Lightbulb as ReasoningIcon,
 	Mic as AudioIcon,
-	Video as VideoIcon
+	Video as VideoIcon,
+	Wrench as ToolUseIcon
 } from '@lucide/svelte';
 import { FileTypeCategory, ModelCapability, ModelModality } from '$lib/enums';
 import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
@@ -49,16 +50,19 @@ export const MODALITY_FLAG_KEYS: Record<
 };

 export const CAPABILITY_ICONS: Record<ModelCapability, Component> = {
-	[ModelCapability.REASONING]: ReasoningIcon
+	[ModelCapability.REASONING]: ReasoningIcon,
+	[ModelCapability.TOOL_USE]: ToolUseIcon
 } as const;

 export const CAPABILITY_LABELS: Record<ModelCapability, string> = {
-	[ModelCapability.REASONING]: 'Reasoning'
+	[ModelCapability.REASONING]: 'Reasoning',
+	[ModelCapability.TOOL_USE]: 'Tool use'
 } as const;

 /** Maps a ModelCapability to the boolean flag it drives on the ModelCapabilities type */
 export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabilities> = {
-	[ModelCapability.REASONING]: 'reasoning'
+	[ModelCapability.REASONING]: 'reasoning',
+	[ModelCapability.TOOL_USE]: 'tools'
 };

 // Shared SVG icon strings for copy and preview buttons
diff --git a/tools/ui/src/lib/constants/model-id.constants.ts b/tools/ui/src/lib/constants/model-id.constants.ts
index 081a13e0e..d4ffc8b0f 100644
--- a/tools/ui/src/lib/constants/model-id.constants.ts
+++ b/tools/ui/src/lib/constants/model-id.constants.ts
@@ -2,35 +2,56 @@
  * Parsing of `org/ModelName[-tag][:quant]` style model IDs.
  */

+import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
+
+/** Any sidecar file type: a draft variant or an auxiliary sidecar like mmproj. */
+export type ModelSidecar = ModelDraftSidecar | ModelAuxSidecar;
+
+/** All sidecar filename tokens: the bare lowercase enum values, e.g. `mtp`, `mmproj`. */
+export const SIDECAR_TOKENS: string[] = [
+	...Object.values(ModelDraftSidecar),
+	...Object.values(ModelAuxSidecar)
+];
+
+/** Separator between token alternatives in the sidecar regexes. */
+const REGEX_ALTERNATION_SEPARATOR = '|';
+
+/** Escape regex metacharacters so a token change cannot silently corrupt the alternation. */
+function escapeRegExp(token: string): string {
+	return token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+const SIDECAR_TOKEN_ALTERNATION = SIDECAR_TOKENS.map(escapeRegExp).join(
+	REGEX_ALTERNATION_SEPARATOR
+);
+
 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. */
 	NOT_FOUND: -1,
-
 	/** Separates `<org>` from `<model>` in a model ID, e.g. `org/ModelName`. */
 	ORG_SEPARATOR: '/',
-
 	/**
 	 * Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`.
 	 * 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: ':',
@@ -38,6 +59,36 @@ export const MODEL_ID = {
 	/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */
 	SEGMENT_SEPARATOR: '-',

+	/**
+	 * Sidecar token between name segments, e.g. `Model-mtp-Q4_0.gguf`,
+	 * `model-eagle3-BF16.gguf`. Captures the name head and tail around the
+	 * token; same case-insensitive rule as the prefix form.
+	 */
+	SIDECAR_INFIX_REGEX: new RegExp(`^(.*)-(${SIDECAR_TOKEN_ALTERNATION})-(.+)$`, 'i'),
+
+	/**
+	 * Sidecar prefix that wraps a model id with a sidecar type, e.g.
+	 * `mtp-<name>.gguf`, `dflash-<name>.gguf`, `dspark-<name>.gguf`,
+	 * `eagle3-<name>.gguf`, `mmproj-<name>.gguf`. Captures the bare type
+	 * token for typed lookup.
+	 *
+	 * The token matches case-insensitively (real repos ship uppercase
+	 * heads, e.g. `Model-MTP-BF16.gguf`) and is normalized through
+	 * `sidecarFromFileToken`; the server's filename grammar
+	 * (common/download.cpp) matches the same segments.
+	 */
+	SIDECAR_PREFIX_REGEX: new RegExp(`^(${SIDECAR_TOKEN_ALTERNATION})-(.*)$`, 'i'),
+
+	/**
+	 * Trailing `-<type>` suffix marking a GGUF with an embedded draft in the
+	 * same weight file (MTP) or a sidecar download entry, e.g.
+	 * `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. An optional `-draft` tail covers
+	 * standalone sidecar files, e.g. `Model-mtp-draft.gguf`. The captured
+	 * prefix is the candidate model id; the caller decides whether it looks
+	 * quantized. Case-insensitive, like the prefix form.
+	 */
+	SIDECAR_SUFFIX_REGEX: new RegExp(`^(.*)-(${SIDECAR_TOKEN_ALTERNATION})(-draft)?$`, '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/index.ts b/tools/ui/src/lib/enums/index.ts
index e0b15bc72..b20875f80 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -67,7 +67,7 @@ export {
 	JsonSchemaType
 } from './mcp.enums';

-export { ModelCapability, ModelModality } from './model.enums';
+export { ModelAuxSidecar, ModelCapability, ModelDraftSidecar, ModelModality } from './model.enums';

 export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';

diff --git a/tools/ui/src/lib/enums/model.enums.ts b/tools/ui/src/lib/enums/model.enums.ts
index db085a27a..177fffe19 100644
--- a/tools/ui/src/lib/enums/model.enums.ts
+++ b/tools/ui/src/lib/enums/model.enums.ts
@@ -6,5 +6,32 @@ export enum ModelModality {
 }

 export enum ModelCapability {
-	REASONING = 'REASONING'
+	REASONING = 'reasoning',
+	TOOL_USE = 'tools'
+}
+
+/**
+ * Speculative-decoding draft sidecars (server spec-type draft-*).
+ * Filenames use the lowercase token, e.g. `mtp-<name>.gguf` or `-mtp` suffix.
+ */
+export enum ModelDraftSidecar {
+	/** DFlash block-diffusion draft (spec-type draft-dflash). */
+	DFLASH = 'dflash',
+	/** DSpark block-diffusion draft (spec-type draft-dspark). */
+	DSPARK = 'dspark',
+	/** EAGLE-3 speculative draft (spec-type draft-eagle3). */
+	EAGLE3 = 'eagle3',
+	/** Multi-token-prediction draft head (spec-type draft-mtp). */
+	MTP = 'mtp'
+}
+
+/**
+ * Non-draft sidecar file types. A sidecar is any auxiliary GGUF file
+ * accompanying the main model weights.
+ */
+export enum ModelAuxSidecar {
+	/** Importance-matrix data used to build imatrix quants; not loaded at serve time. */
+	IMATRIX = 'imatrix',
+	/** Multimodal projector: unlocks vision and/or audio input modalities. */
+	MMPROJ = 'mmproj'
 }
diff --git a/tools/ui/src/lib/services/models.service.ts b/tools/ui/src/lib/services/models.service.ts
index 070c935c6..21a9b22a1 100644
--- a/tools/ui/src/lib/services/models.service.ts
+++ b/tools/ui/src/lib/services/models.service.ts
@@ -7,7 +7,7 @@
  */

 import { base } from '$app/paths';
-import { API_MODELS, MODEL_ID } from '$lib/constants';
+import { API_MODELS, MODEL_ID, type ModelSidecar } from '$lib/constants';
 import { ServerModelStatus } from '$lib/enums';
 import type { ParsedModelId } from '$lib/types/models';
 import {
@@ -15,6 +15,7 @@ import {
 	apiPost,
 	extractSseDataPayload,
 	normalizeModelName,
+	sidecarFromFileToken,
 	splitSseRecords
 } from '$lib/utils';
 import { getAuthHeaders } from '$lib/utils/api-headers';
@@ -22,6 +23,30 @@ import { getAuthHeaders } from '$lib/utils/api-headers';
 export class ModelsService {
 	private static readonly SSE_RECONNECT_MS = 1000;

+	/**
+	 * Build the `<repo>:<tag>` string expected by POST /models from a parsed
+	 * filename quant + optional sidecar type. Used by the model download
+	 * dialog so callers don't have to know about the tag conventions.
+	 *
+	 * @param repoId - HuggingFace repo id (e.g. `ggml-org/gemma-3-4b-it-GGUF`)
+	 * @param quant - Quantization token, e.g. `Q4_K_M`
+	 * @param sidecar - Sidecar type, as its lowercase filename token (e.g. `mtp`)
+	 * @returns Repo id possibly suffixed with `:tag`
+	 */
+	static buildDownloadTag(
+		repoId: string,
+		quant: string | null,
+		sidecar: ModelSidecar | null
+	): string {
+		if (!quant && !sidecar) return repoId;
+
+		if (!quant) return `${repoId}:${sidecar}`;
+
+		const tag = sidecar ? `${quant}-${sidecar}` : quant;
+
+		return `${repoId}:${tag}`;
+	}
+
 	/**
 	 * Check if a model is loaded based on its metadata.
 	 *
@@ -97,11 +122,46 @@ export class ModelsService {
 			params: null,
 			quantization: null,
 			raw: modelId,
+			sidecar: null,
 			tags: []
 		};
+
 		// 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
-		const 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_REGEX);
+
+		if (prefixMatch) {
+			result.sidecar = sidecarFromFileToken(prefixMatch[1].toLowerCase());
+			source = prefixMatch[2];
+
+			// 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_REGEX.test(source)) {
+				result.quantization = source.toUpperCase();
+				source = '';
+			}
+		} else {
+			// 0b. Detect `-<type>` suffix (`-mtp`, `-dflash`, `-dspark`, `-eagle3`).
+			//     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_REGEX);
+
+			if (suffixMatch) {
+				const candidate = suffixMatch[1];
+				const headSeg = candidate.split(MODEL_ID.SEGMENT_SEPARATOR).pop();
+
+				if (headSeg && MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(headSeg)) {
+					result.sidecar = sidecarFromFileToken(suffixMatch[2].toLowerCase());
+					source = candidate;
+				}
+			}
+		}
+
 		// 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`)
 		const colonIdx = source.indexOf(MODEL_ID.QUANTIZATION_SEPARATOR);

@@ -132,7 +192,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);
 			}
@@ -147,8 +207,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 {
@@ -165,10 +225,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/src/lib/types/models.d.ts b/tools/ui/src/lib/types/models.d.ts
index 0b7523113..03c723e1a 100644
--- a/tools/ui/src/lib/types/models.d.ts
+++ b/tools/ui/src/lib/types/models.d.ts
@@ -1,3 +1,4 @@
+import type { ModelSidecar } from '$lib/constants/model-id.constants';
 import type { ApiModelDataEntry, ApiModelDetails, ApiModelLoadStage } from '$lib/types/api';

 export interface ModelModalities {
@@ -8,6 +9,7 @@ export interface ModelModalities {

 export interface ModelCapabilities {
 	reasoning: boolean;
+	tools: boolean;
 }

 export interface ModelOption {
@@ -42,6 +44,7 @@ export interface ParsedModelId {
 	params: string | null;
 	activatedParams: string | null;
 	quantization: string | null;
+	sidecar: ModelSidecar | null;
 	tags: string[];
 }

diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 721618c48..8896ea234 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -107,6 +107,9 @@ export {
 // Model name utilities
 export { normalizeModelName, isValidModelName } from './model-names';

+// Sidecar token utilities
+export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
+
 // Portal utilities
 export { portalToBody } from './portal-to-body';

diff --git a/tools/ui/src/lib/utils/sidecars.ts b/tools/ui/src/lib/utils/sidecars.ts
new file mode 100644
index 000000000..a3eb3841f
--- /dev/null
+++ b/tools/ui/src/lib/utils/sidecars.ts
@@ -0,0 +1,19 @@
+import { type ModelSidecar, SIDECAR_TOKENS } from '$lib/constants';
+import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
+
+const SIDECAR_TOKEN_SET = new Set<string>(SIDECAR_TOKENS);
+const DRAFT_SIDECAR_SET = new Set<string>(Object.values(ModelDraftSidecar));
+const AUX_SIDECAR_SET = new Set<string>(Object.values(ModelAuxSidecar));
+
+/** Map a lowercase filename token (e.g. `mtp`) to its sidecar enum value. */
+export function sidecarFromFileToken(token: string): ModelSidecar | null {
+	return SIDECAR_TOKEN_SET.has(token) ? (token as ModelSidecar) : null;
+}
+
+export function isDraftSidecar(sidecar: ModelSidecar): sidecar is ModelDraftSidecar {
+	return DRAFT_SIDECAR_SET.has(sidecar);
+}
+
+export function isAuxSidecar(sidecar: ModelSidecar): sidecar is ModelAuxSidecar {
+	return AUX_SIDECAR_SET.has(sidecar);
+}
diff --git a/tools/ui/tests/unit/model-id-parser.test.ts b/tools/ui/tests/unit/model-id-parser.test.ts
index 5903f196a..3e2a1c5c6 100644
--- a/tools/ui/tests/unit/model-id-parser.test.ts
+++ b/tools/ui/tests/unit/model-id-parser.test.ts
@@ -1,3 +1,4 @@
+import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
 import { ModelsService } from '$lib/services/models.service';
 import { describe, expect, it } from 'vitest';

@@ -12,6 +13,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: null,
 			raw: 'model-name-1',
+			sidecar: null,
 			tags: []
 		});

@@ -22,6 +24,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: null,
 			raw: 'org/model-name-2',
+			sidecar: null,
 			tags: []
 		});
 	});
@@ -105,6 +108,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: 'Q2_K_XL',
 			raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
+			sidecar: null,
 			tags: []
 		});

@@ -115,6 +119,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: 'Q4_K_XL',
 			raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
+			sidecar: null,
 			tags: []
 		});

@@ -125,6 +130,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: null,
 			raw: 'org/Model-Name-GGUF',
+			sidecar: null,
 			tags: []
 		});
 	});
@@ -137,6 +143,7 @@ describe('parseModelId', () => {
 			params: '8B',
 			quantization: null,
 			raw: 'meta-llama/Llama-3.1-8B',
+			sidecar: null,
 			tags: []
 		});

@@ -147,6 +154,7 @@ describe('parseModelId', () => {
 			params: '120B',
 			quantization: 'MXFP4',
 			raw: 'openai/gpt-oss-120b-MXFP4',
+			sidecar: null,
 			tags: []
 		});

@@ -157,6 +165,7 @@ describe('parseModelId', () => {
 			params: '20B',
 			quantization: 'Q4_K_M',
 			raw: 'openai/gpt-oss-20b:Q4_K_M',
+			sidecar: null,
 			tags: []
 		});

@@ -167,6 +176,7 @@ describe('parseModelId', () => {
 			params: '30B',
 			quantization: 'BF16',
 			raw: 'Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16',
+			sidecar: null,
 			tags: ['Instruct', '1M']
 		});
 	});
@@ -179,6 +189,7 @@ describe('parseModelId', () => {
 			params: '17B',
 			quantization: 'Q4_K_M',
 			raw: 'meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M',
+			sidecar: null,
 			tags: ['16E', 'Instruct']
 		});

@@ -189,6 +200,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: 'IQ4_XS',
 			raw: 'MiniMaxAI/MiniMax-M2-IQ4_XS',
+			sidecar: null,
 			tags: []
 		});

@@ -199,6 +211,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: 'UD-Q3_K_XL',
 			raw: 'MiniMaxAI/MiniMax-M2-UD-Q3_K_XL',
+			sidecar: null,
 			tags: []
 		});

@@ -209,6 +222,7 @@ describe('parseModelId', () => {
 			params: '123B',
 			quantization: 'Q4_K_M',
 			raw: 'mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M',
+			sidecar: null,
 			tags: ['Instruct', '2512']
 		});

@@ -219,6 +233,7 @@ describe('parseModelId', () => {
 			params: '24B',
 			quantization: 'Q8_0',
 			raw: 'mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0',
+			sidecar: null,
 			tags: ['Instruct', '2512']
 		});

@@ -229,6 +244,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: 'MXFP4_MOE',
 			raw: 'noctrex/GLM-4.7-Flash-MXFP4_MOE',
+			sidecar: null,
 			tags: []
 		});

@@ -239,6 +255,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: 'Q4_K_M',
 			raw: 'Qwen/Qwen3-Coder-Next-Q4_K_M',
+			sidecar: null,
 			tags: []
 		});

@@ -249,6 +266,7 @@ describe('parseModelId', () => {
 			params: '120B',
 			quantization: 'Q4_K_M',
 			raw: 'openai/gpt-oss-120b-Q4_K_M',
+			sidecar: null,
 			tags: []
 		});

@@ -259,6 +277,7 @@ describe('parseModelId', () => {
 			params: '20B',
 			quantization: 'F16',
 			raw: 'openai/gpt-oss-20b-F16',
+			sidecar: null,
 			tags: []
 		});

@@ -269,6 +288,7 @@ describe('parseModelId', () => {
 			params: null,
 			quantization: 'Q4_K_M',
 			raw: 'nomic-embed-text-v2-moe.Q4_K_M',
+			sidecar: null,
 			tags: []
 		});
 	});
@@ -304,4 +324,41 @@ describe('parseModelId', () => {
 			tags: ['it']
 		});
 	});
+
+	it('parses sidecar file tokens', () => {
+		// sidecar prefix: bare filename or multi-slash path reduces to the filename
+		expect(parseModelId('mtp-Q4_0.gguf')).toMatchObject({
+			quantization: 'Q4_0',
+			sidecar: ModelDraftSidecar.MTP
+		});
+
+		expect(parseModelId('ggml-org/Model-GGUF/mtp-Q4_0.gguf')).toMatchObject({
+			quantization: 'Q4_0',
+			sidecar: ModelDraftSidecar.MTP
+		});
+
+		expect(parseModelId('ggml-org/Model-GGUF/mmproj-F16.gguf')).toMatchObject({
+			quantization: 'F16',
+			sidecar: ModelAuxSidecar.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: ModelDraftSidecar.MTP
+		});
+
+		// a model literally named MyModel-mtp is not a draft
+		expect(parseModelId('ggml-org/MyModel-mtp')).toMatchObject({
+			modelName: 'MyModel-mtp',
+			sidecar: null
+		});
+
+		// no sidecar
+		expect(parseModelId('ggml-org/model-Q4_K_M')).toMatchObject({
+			quantization: 'Q4_K_M',
+			sidecar: null
+		});
+	});
 });