diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte index 1faeb0c4a5..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'; @@ -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) })); diff --git a/tools/ui/src/lib/constants/icons.constants.ts b/tools/ui/src/lib/constants/icons.constants.ts index a0ce22f95e..ce32f0e1e9 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.REASONING]: ReasoningIcon + [ModelCapability.REASONING]: ReasoningIcon, + [ModelCapability.TOOL_USE]: ToolUseIcon } as const; export const CAPABILITY_LABELS: Record = { - [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.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 081a13e0e6..53d361122d 100644 --- a/tools/ui/src/lib/constants/model-id.constants.ts +++ b/tools/ui/src/lib/constants/model-id.constants.ts @@ -2,35 +2,48 @@ * 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 = '|'; +const SIDECAR_TOKEN_ALTERNATION = SIDECAR_TOKENS.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 `` from `` 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 +51,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-.gguf`, `dflash-.gguf`, `dspark-.gguf`, + * `eagle3-.gguf`, `mmproj-.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 `-` 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 e0b15bc729..b20875f80f 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 db085a27a7..177fffe19a 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-.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 070c935c65..21a9b22a16 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 `:` 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 `-` 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 0b7523113d..03c723e1ae 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 721618c48f..8896ea234e 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 0000000000..a3eb3841fd --- /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(SIDECAR_TOKENS); +const DRAFT_SIDECAR_SET = new Set(Object.values(ModelDraftSidecar)); +const AUX_SIDECAR_SET = new Set(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 5903f196ab..3e2a1c5c6e 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: - 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 + }); + }); });