Browse Source

init: 初始化项目

kekeZack 1 tuần trước cách đây
mục cha
commit
16a00e440d

+ 386 - 0
docs/llama.cpp-参数详解.md

@@ -0,0 +1,386 @@
+# llama.cpp 最新版介绍与参数详解
+
+> **版本**: 9208 (5511965b1) | **构建**: Clang 19.1.5 Windows x86_64 | **CUDA**: 12.4  
+> **项目地址**: https://github.com/ggml-org/llama.cpp  
+> **本机路径**: `E:\llama-bin-win-cuda-12.4-x64\`
+
+---
+
+## 一、llama.cpp 简介
+
+llama.cpp 是一个高性能的 C/C++ 推理引擎,专注于在本地运行 GGUF 格式的大语言模型。它支持:
+
+- **CPU + GPU 混合推理** — 通过 `-ngl` 参数将部分层卸载到 GPU(CUDA / Vulkan / Metal / SYCL)
+- **GGUF 量化格式** — 以 Q4_K_M、Q8_0 等量化等级平衡速度与质量
+- **OpenAI 兼容 API** — `llama-server` 提供 `/v1/chat/completions`、`/v1/audio/transcriptions` 等标准端点
+- **Router Server 模式** — 多模型管理、自动加载/卸载、JIT 模型切换
+- **Flash Attention** — 大幅加速长上下文推理
+- **推测解码 (Speculative Decoding)** — 用小模型草稿加速大模型
+- **多模态支持** — 视觉(LLaVA、Qwen2-VL)、语音(Whisper)、ASR(Qwen3-ASR)
+
+---
+
+## 二、二进制工具集
+
+| 可执行文件 | 用途 |
+|---|---|
+| `llama-server.exe` | **主服务器** — OpenAI API 兼容服务,支持 Router 多模型管理 |
+| `llama-cli.exe` | 命令行推理客户端 |
+| `llama-bench.exe` | 性能基准测试 |
+| `llama-quantize.exe` | 模型量化工具 |
+| `llama-gguf-split.exe` | GGUF 文件拆分/合并 |
+| `llama-perplexity.exe` | 困惑度评估 |
+| `llama-tokenize.exe` | 分词/反分词工具 |
+| `llama-qwen2vl-cli.exe` | Qwen2-VL 视觉推理 CLI |
+| `llama-llava-cli.exe` | LLaVA 视觉推理 CLI |
+| `llama-minicpmv-cli.exe` | MiniCPM-V 视觉推理 CLI |
+| `llama-gemma3-cli.exe` | Gemma3 视觉推理 CLI |
+
+---
+
+## 三、llama-server 完整参数详解
+
+### 3.1 模型加载
+
+| 参数 | 说明 | 默认值 |
+|---|---|---|
+| `-m, --model FNAME` | 模型文件路径 | 无 |
+| `-mu, --model-url URL` | 模型下载 URL | 无 |
+| `-hf, --hf-repo <user>/<model>[:quant]` | HuggingFace 仓库自动下载 (量化可选, 默认 Q4_K_M) | 无 |
+| `-hff, --hf-file FILE` | 覆盖 --hf-repo 中的具体文件名 | 无 |
+| `-hft, --hf-token TOKEN` | HuggingFace 访问令牌 | `HF_TOKEN` 环境变量 |
+| `-mm, --mmproj FILE` | 多模态投影器 (如 Qwen-ASR 的 mmproj) | 无 |
+| `-ngl, --gpu-layers N` | 卸载到 GPU 的层数, 可选 `auto` 或 `all` | `auto` |
+| `-sm, --split-mode {none,layer,row,tensor}` | 多 GPU 拆分模式 | `layer` |
+| `-ts, --tensor-split N0,N1,...` | 多 GPU 权重分配比例, 如 `3,1` | 无 |
+| `-mg, --main-gpu INDEX` | 主 GPU 索引 | `0` |
+| `-dev, --device <dev1,dev2,...>` | 指定 GPU 设备列表 | 无 |
+| `--list-devices` | 列出可用设备并退出 | — |
+| `--fit [on/off]` | 自动调整未设置参数适配设备内存 | `on` |
+| `-fitt, --fit-target MiB` | --fit 的每设备余量目标 | `1024` |
+
+### 3.2 推理参数
+
+| 参数 | 说明 | 默认值 |
+|---|---|---|
+| `-c, --ctx-size N` | 提示上下文大小 (0 = 从模型读取) | `0` |
+| `-n, --n-predict N` | 预测 token 数量 (-1 = 无限) | `-1` |
+| `-b, --batch-size N` | 逻辑最大批大小 | `2048` |
+| `-ub, --ubatch-size N` | 物理最大批大小 | `512` |
+| `-t, --threads N` | 生成时 CPU 线程数 | `-1` |
+| `-tb, --threads-batch N` | 批处理时 CPU 线程数 | 同 `--threads` |
+| `-fa, --flash-attn [on/off/auto]` | Flash Attention | `auto` |
+| `--mlock` | 锁定模型在 RAM 防止换出 | 禁用 |
+| `--no-mmap` | 禁用内存映射 (加载慢但减少 pageout) | 启用 mmap |
+| `-ctk, --cache-type-k TYPE` | K 缓存数据类型 | `f16` |
+| `-ctv, --cache-type-v TYPE` | V 缓存数据类型 | `f16` |
+| `--rope-scaling {none,linear,yarn}` | RoPE 频率缩放方法 | `linear` |
+| `--rope-scale N` | RoPE 上下文缩放因子 | 无 |
+| `--rope-freq-base N` | RoPE 基频 | 从模型读取 |
+| `--yarn-ext-factor N` | YaRN 外推混合因子 | `-1.0` |
+
+### 3.3 Sampling (采样) 参数
+
+| 参数 | 说明 | 默认值 |
+|---|---|---|
+| `--samplers LIST` | 采样器链, 用 `;` 分隔 | `penalties;dry;top_n_sigma;top_k;typ_p;top_p;min_p;xtc;temperature` |
+| `-s, --seed N` | RNG 种子 (-1 = 随机) | `-1` |
+| `--temp, --temperature N` | 温度 | `0.80` |
+| `--top-k N` | Top-K 采样 (0 = 禁用) | `40` |
+| `--top-p N` | Top-P 采样 (1.0 = 禁用) | `0.95` |
+| `--min-p N` | Min-P 采样 (0.0 = 禁用) | `0.05` |
+| `--repeat-penalty N` | 重复惩罚 (1.0 = 禁用) | `1.00` |
+| `--presence-penalty N` | 存在惩罚 | `0.00` |
+| `--frequency-penalty N` | 频率惩罚 | `0.00` |
+| `--mirostat N` | Mirostat (0=禁用, 1= v1, 2=v2) | `0` |
+| `--mirostat-lr N` | Mirostat 学习率 | `0.10` |
+| `--mirostat-ent N` | Mirostat 目标熵 | `5.00` |
+| `--dry-multiplier N` | DRY 采样乘数 | `0.00` |
+| `--xtc-probability N` | XTC 概率 | `0.00` |
+| `--grammar GRAMMAR` | BNF 文法约束生成 | 无 |
+| `-j, --json-schema SCHEMA` | JSON Schema 约束 | 无 |
+| `--dynatemp-range N` | 动态温度范围 | `0.00` |
+
+### 3.4 服务器参数
+
+| 参数 | 说明 | 默认值 |
+|---|---|---|
+| `--host HOST` | 监听 IP 地址 | `127.0.0.1` |
+| `--port PORT` | 监听端口 | `8080` |
+| `-to, --timeout N` | 读写超时 (秒) | `600` |
+| `--threads-http N` | HTTP 处理线程数 | `-1` |
+| `-np, --parallel N` | 服务器槽位数 (-1 = auto) | `-1` |
+| `--api-key KEY` | API 密钥认证 | 无 |
+| `--embedding` | 仅嵌入模式 | 禁用 |
+| `--rerank` | 启用重排序端点 | 禁用 |
+| `-a, --alias STRING` | 模型别名 (API 用) | 无 |
+| `--ui, --no-ui` | 启用 Web UI | 启用 |
+| `--metrics` | Prometheus 指标端点 | 禁用 |
+| `--jinja` | Jinja 模板引擎 | 启用 |
+| `--reasoning-format FORMAT` | 思考标签输出格式 (none/deepseek/deepseek-legacy) | `auto` |
+| `--slot-save-path PATH` | 槽 KV 缓存保存路径 | 禁用 |
+| `-sps, --slot-prompt-similarity N` | 槽提示匹配相似度阈值 | `0.10` |
+| `--chat-template NAME` | 内置聊天模板名称 | 从模型读取 |
+| `--cache-prompt` | 启用提示缓存 | 启用 |
+| `--cache-reuse N` | KV 偏移重用的最小块大小 | `0` |
+
+### 3.5 Router Server (多模型管理) ★ 核心功能
+
+这些参数实现了类似 LM Studio 的「多模型热切换 + 自动卸载」能力:
+
+| 参数 | 说明 | 默认值 |
+|---|---|---|
+| `--models-dir PATH` | **模型目录** — 指定 LM Studio 模型路径, 自动扫描所有 GGUF | 禁用 |
+| `--models-preset PATH` | **模型预设文件** — INI 格式描述每个模型的参数 | 禁用 |
+| `--models-max N` | **最大同时加载模型数** — 设为 `1` 即实现"仅保留最近加载的 JIT 模型" | `4` |
+| `--models-autoload` | **自动加载** — 请求时自动加载未加载的模型 | 启用 |
+| `--cache-ram N` | **KV 缓存最大 RAM** (MiB) — 旧模型/槽的缓存会自动释放 | `8192` |
+| `--cache-idle-slots` | **空闲槽清理** — 新任务到达时保存并清除空闲槽的 KV 缓存 | 启用 |
+| `--sleep-idle-seconds N` | **★★★★★ 闲时自动卸载 TTL** — 服务器空闲 N 秒后自动卸载模型释放显存 (-1 = 禁用) | `-1` |
+| `--context-shift` | **上下文偏移** — 无限文本生成时的上下文移位 | 禁用 |
+
+#### Router Server 模型预设文件 (.ini) 格式示例
+
+```ini
+[qwen3-asr]
+model = C:\Users\kekezack\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\Qwen3-ASR-1.7B-Q8_0.gguf
+mmproj = C:\Users\kekezack\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\mmproj-Qwen3-ASR-1.7B-bf16.gguf
+ngl = 99
+ctx-size = 8192
+alias = asr
+
+[qwen3-8b]
+model = C:\Users\kekezack\.lmstudio\models\lmstudio-community\Qwen3-8B-GGUF\Qwen3-8B-Q4_K_M.gguf
+ngl = 99
+ctx-size = 32768
+alias = chat
+
+[gemma3-12b]
+model = C:\Users\kekezack\.lmstudio\models\lmstudio-community\gemma-3-12b-it-GGUF\gemma-3-12b-it-Q4_K_M.gguf
+mmproj = C:\Users\kekezack\.lmstudio\models\lmstudio-community\gemma-3-12b-it-GGUF\mmproj-model-f16.gguf
+ngl = 99
+ctx-size = 8192
+alias = vision
+```
+
+### 3.6 推测解码 (Speculative Decoding)
+
+| 参数 | 说明 | 默认值 |
+|---|---|---|
+| `--spec-type TYPE` | 推测解码类型 (none / draft-simple / draft-eagle3 / ngram-cache 等) | `none` |
+| `-md, --model-draft FNAME` | 草稿模型路径 | 无 |
+| `--spec-draft-n-max N` | 最大草稿 token 数 | `16` |
+| `--spec-draft-n-min N` | 最小草稿 token 数 | `0` |
+| `--spec-draft-p-split P` | 推测解码分裂概率 | `0.10` |
+| `--spec-draft-p-min P` | 最小推测概率 (贪心) | `0.75` |
+
+### 3.7 多模态 / 特殊模型
+
+| 参数 | 说明 |
+|---|---|
+| `--image-min-tokens N` | 图像最小 token 数 (动态分辨率视觉模型) |
+| `--image-max-tokens N` | 图像最大 token 数 |
+| `-mv, --model-vocoder FNAME` | TTS 声码器模型路径 |
+| `--tools LIST` | 内置 AI 代理工具 (read_file, grep_search 等) |
+
+---
+
+## 四、类 LM Studio 的启动方式
+
+这一节只讲最实用的用法: 直接复用 LM Studio 下载的模型目录, 保留它的“按请求自动切换模型”体验, 同时补上 LM Studio 默认不强调的两项能力:
+
+- **TTL 闲时自动卸载**: 通过 `--sleep-idle-seconds 300` 设置 300 秒无请求后自动卸载模型, 释放显存。
+- **强制单模型驻留**: 通过 `--models-max 1` 只保留最近加载的 JIT 模型, 新模型加载时自动卸载前一个。
+
+如果你的目标是“尽可能像 LM Studio, 但更省显存”, 那么 **Router Server + TTL + `--models-max 1`** 就是首选方案。
+
+### 4.1 LM Studio 模型目录
+
+LM Studio 下载的模型通常位于 `%USERPROFILE%\.lmstudio\models\` 下, 按 `发布者/模型目录/GGUF文件` 组织。例如:
+
+```text
+%USERPROFILE%\.lmstudio\models\
+  ggml-org\Qwen3-ASR-1.7B-GGUF\Qwen3-ASR-1.7B-Q8_0.gguf
+  ggml-org\Qwen3-ASR-1.7B-GGUF\mmproj-Qwen3-ASR-1.7B-bf16.gguf
+  lmstudio-community\Qwen3-8B-GGUF\Qwen3-8B-Q4_K_M.gguf
+```
+
+这意味着你可以直接让 `llama-server` 扫描这个目录, 不需要手工复制模型。
+
+### 4.2 最推荐: Router Server + TTL + 单模型驻留
+
+这是最接近 LM Studio 使用体验, 同时又能自动控显存的方案。
+
+#### 推荐启动命令
+
+```bat
+"%LLAMA_BIN%\llama-server.exe" --models-dir "%USERPROFILE%\.lmstudio\models" --models-preset "%CD%\models-preset.ini" --models-autoload --models-max 1 --sleep-idle-seconds 300 --cache-ram 2048 --cache-idle-slots --port 18003 --host 127.0.0.1 -ngl auto -c 32768 --flash-attn auto --jinja
+```
+
+#### 这个组合为什么像 LM Studio
+
+| 参数 | 作用 | 对 LM Studio 体验的意义 |
+|---|---|---|
+| `--models-dir "%USERPROFILE%\.lmstudio\models"` | 扫描 LM Studio 模型目录 | 直接复用 LM Studio 已下载模型 |
+| `--models-preset "%CD%\models-preset.ini"` | 为不同模型声明 `ctx-size`、`ngl`、`mmproj`、别名 | 类似 LM Studio 给不同模型保存配置 |
+| `--models-autoload` | 请求某个未加载模型时自动加载 | 保留“点哪个模型就切哪个模型”的体验 |
+| `--models-max 1` | 最多只允许 1 个模型同时驻留 | 仅保留最近加载的 JIT 模型, 新模型会顶掉前一个 |
+| `--sleep-idle-seconds 300` | 300 秒无请求后自动卸载模型 | 闲时彻底释放显存 |
+| `--cache-idle-slots` | 空闲时清理槽位 KV | 降低上下文缓存占用 |
+| `--cache-ram 2048` | 限制 KV 缓存总 RAM | 防止 Router 模式下缓存持续堆积 |
+
+#### 推荐理解方式
+
+- `--models-max 1` 解决的是“**同一时刻只驻留一个模型**”。
+- `--sleep-idle-seconds 300` 解决的是“**空闲 300 秒后连这个唯一模型也卸载**”。
+- 两者配合后, 行为就是:
+  1. 收到请求时按需加载目标模型。
+  2. 如果请求切换到另一个模型, 旧模型自动卸载。
+  3. 如果 300 秒没有新请求, 当前模型也自动卸载。
+
+这就是“尽可能像 LM Studio, 但支持闲时自动卸载”的核心配置。
+
+### 4.3 单模型直连模式
+
+如果你只跑一个固定模型, 可以不用 Router, 直接指定 `-m` 和 `--mmproj`。
+
+#### 纯单模型聊天
+
+```bat
+"%LLAMA_BIN%\llama-server.exe" -m "%USERPROFILE%\.lmstudio\models\lmstudio-community\Qwen3-8B-GGUF\Qwen3-8B-Q4_K_M.gguf" --port 18003 -ngl auto -c 32768 -np 4
+```
+
+#### 单模型 ASR + TTL 自动卸载
+
+```bat
+"%LLAMA_BIN%\llama-server.exe" -m "%USERPROFILE%\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\Qwen3-ASR-1.7B-Q8_0.gguf" --mmproj "%USERPROFILE%\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\mmproj-Qwen3-ASR-1.7B-bf16.gguf" --port 18003 -ngl 99 -c 8192 -np 4 --models-max 1 --sleep-idle-seconds 300
+```
+
+这个模式适合专门给本项目的语音转写接口使用。虽然这里只有一个模型, 但保留 `--sleep-idle-seconds 300` 依然有意义: 空闲时会自动释放显存。
+
+### 4.4 `models-preset.ini` 的作用
+
+`--models-preset` 让 Router 知道每个模型要用什么附加参数, 尤其适合以下几类模型:
+
+- ASR / 多模态模型: 需要 `mmproj`
+- 大上下文聊天模型: 需要单独的 `ctx-size`
+- 不同体量模型: 需要不同 `ngl`
+- 对外暴露 API: 希望提供固定 `alias`
+
+示例:
+
+```ini
+[qwen3-asr]
+model = %USERPROFILE%\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\Qwen3-ASR-1.7B-Q8_0.gguf
+mmproj = %USERPROFILE%\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\mmproj-Qwen3-ASR-1.7B-bf16.gguf
+ngl = 99
+ctx-size = 8192
+alias = asr
+
+[qwen3-8b]
+model = %USERPROFILE%\.lmstudio\models\lmstudio-community\Qwen3-8B-GGUF\Qwen3-8B-Q4_K_M.gguf
+ngl = 99
+ctx-size = 32768
+alias = chat
+```
+
+### 4.5 模型切换 API
+
+启动 Router Server 后, 客户端只要在请求里指定 `model`, `llama-server` 就会像 LM Studio 一样按需切换。
+
+```bash
+curl http://localhost:18003/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "C:\\Users\\kekezack\\.lmstudio\\models\\lmstudio-community\\Qwen3-8B-GGUF\\Qwen3-8B-Q4_K_M.gguf",
+    "messages": [{"role": "user", "content": "你好"}]
+  }'
+
+curl http://localhost:18003/v1/audio/transcriptions \
+  -F "model=Qwen3-ASR-1.7B-Q8_0.gguf" \
+  -F "file=@audio.wav"
+```
+
+如果启用了 `alias`, 也可以优先用别名, 这样客户端不必写完整文件路径。
+
+---
+
+## 五、常用启动模板
+
+### 模板 1: 最简单单模型聊天
+
+```bat
+llama-server.exe -m "model.gguf" --port 8080 -ngl auto -c 8192
+```
+
+### 模板 2: 类 LM Studio 的多模型 Router
+
+```bat
+llama-server.exe --models-dir "%USERPROFILE%\.lmstudio\models" --models-preset "models-preset.ini" --models-autoload --port 8080 -ngl auto -c 32768 --flash-attn auto
+```
+
+适合追求“像 LM Studio 一样自动切模型”, 但不强制限制只驻留一个模型的场景。
+
+### 模板 3: 类 LM Studio + TTL + 强制单模型驻留
+
+```bat
+llama-server.exe --models-dir "%USERPROFILE%\.lmstudio\models" --models-preset "models-preset.ini" --models-autoload --models-max 1 --sleep-idle-seconds 300 --cache-ram 2048 --cache-idle-slots --port 8080 -ngl auto -c 32768 --flash-attn auto
+```
+
+这是本文最推荐的模板:
+
+- 行为上接近 LM Studio
+- 300 秒空闲自动卸载
+- 新模型加载时自动卸载前一个模型
+
+### 模板 4: 本项目 Qwen3-ASR 服务
+
+```bat
+llama-server.exe -m "%USERPROFILE%\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\Qwen3-ASR-1.7B-Q8_0.gguf" --mmproj "%USERPROFILE%\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\mmproj-Qwen3-ASR-1.7B-bf16.gguf" --port 18003 -ngl 99 -c 8192 -np 4 --models-max 1 --sleep-idle-seconds 300
+```
+
+适合只给语音转写服务使用, 启动简单, 空闲后也会释放显存。
+
+---
+
+## 六、性能与资源建议
+
+| 场景 | 推荐参数 |
+|---|---|
+| 显存充足 (24GiB+) | `-ngl all -fa on -c 32768 --no-mmap` |
+| 显存紧张 (8GiB) | `--models-max 1 --sleep-idle-seconds 300 --cache-ram 1024 -ngl auto -fa auto` |
+| 追求速度 | `-t 8 -tb 8` |
+| 长上下文 | `-fa on --cache-reuse 256` |
+| 多请求并发 | `-np 4` 或更高, 按 CPU/GPU 能力调整 |
+
+### 建议组合
+
+- 想最大限度模仿 LM Studio: `--models-dir` + `--models-preset` + `--models-autoload`
+- 想控制显存驻留: 再加 `--models-max 1`
+- 想空闲自动释放显存: 再加 `--sleep-idle-seconds 300`
+- 想避免 KV 缓存吃满内存: 再加 `--cache-ram` 和 `--cache-idle-slots`
+
+---
+
+## 七、常见问题
+
+**Q: 哪个方案最像 LM Studio?**  
+A: `Router Server + --models-dir + --models-preset + --models-autoload` 最像 LM Studio。如果你还希望它在空闲时释放显存, 就再加 `--models-max 1 --sleep-idle-seconds 300`。
+
+**Q: `--models-max 1` 到底做了什么?**  
+A: 它强制最多只保留 1 个已加载模型。请求切换到新模型时, 旧模型会被自动卸载, 因而实现“仅保留最近加载的 JIT 模型”。
+
+**Q: `--sleep-idle-seconds 300` 到底做了什么?**  
+A: 它设置 TTL 为 300 秒。服务器在连续 300 秒没有请求时, 会把当前模型卸载掉。下一次请求到来时再自动重载。
+
+**Q: `--models-max 1` 和 `-np 4` 冲突吗?**  
+A: 不冲突。`--models-max 1` 控制的是同时驻留的模型数, `-np 4` 控制的是单个模型可服务的并发槽位数。
+
+**Q: `--sleep-idle-seconds` 和 `--cache-ram` / `--cache-idle-slots` 有什么区别?**  
+A: `--sleep-idle-seconds` 是卸载整个模型权重, 目标是释放显存。`--cache-ram` 和 `--cache-idle-slots` 主要管 KV 缓存, 目标是控制上下文缓存占用。两者解决的问题不同, 但通常建议一起开。
+
+**Q: 如何确认 TTL 自动卸载生效了?**  
+A: 关注服务日志。空闲超时后通常会看到 idle / unload 相关日志; 开启更详细日志时更容易确认模型被卸载和重新加载。
+
+**Q: 支持哪些 GGUF 量化等级?**  
+A: 从低到高通常可理解为 Q2_K < Q3_K < Q4_K < Q5_K < Q6_K < Q8_0 < F16。常用推荐是 `Q4_K_M` 兼顾体积与效果, `Q8_0` 更偏质量。

+ 107 - 0
docs/superpowers/plans/2026-06-10-gguf-metadata-preset-generation.md

@@ -0,0 +1,107 @@
+# GGUF Metadata Preset Generation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `generate_models_preset.py` derive richer `models-preset.ini` values from GGUF metadata only, annotate key numeric values with min/max comments, and stop defaulting to backup creation.
+
+**Architecture:** Keep the work inside `generate_models_preset.py`. Tighten model classification to metadata-backed signals only, add explicit value-selection helpers for model-specific fields, and extend INI rendering so key numeric values are preceded by compact range comments. Cover the new behavior with focused unit tests.
+
+**Tech Stack:** Python 3, `unittest`, existing preset generator script
+
+---
+
+### Task 1: Lock expected behavior with tests
+
+**Files:**
+- Create: `tests/test_generate_models_preset.py`
+- Modify: `generate_models_preset.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+class ClassificationTests(unittest.TestCase):
+    def test_moe_detection_uses_metadata_not_filename(self):
+        ...
+
+class PresetValueTests(unittest.TestCase):
+    def test_moe_values_include_cache_types_and_ncmoe(self):
+        ...
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m unittest tests.test_generate_models_preset -v`
+Expected: FAIL because the new tests assert metadata-only behavior and richer field output that the current implementation does not provide.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+def classify_model_family(entry: ModelEntry) -> str:
+    ...
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m unittest tests.test_generate_models_preset -v`
+Expected: PASS
+
+### Task 2: Extend preset rendering and CLI defaults
+
+**Files:**
+- Modify: `generate_models_preset.py`
+- Test: `tests/test_generate_models_preset.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+def test_render_ini_emits_range_comments_for_numeric_values(self):
+    ...
+
+def test_parser_disables_backup_by_default(self):
+    ...
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m unittest tests.test_generate_models_preset -v`
+Expected: FAIL because current output omits the new comments and currently enables backup by default.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+def build_value_comments(...):
+    ...
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m unittest tests.test_generate_models_preset -v`
+Expected: PASS
+
+### Task 3: Verify against generated preset output
+
+**Files:**
+- Modify if needed: `generate_models_preset.py`
+
+- [ ] **Step 1: Generate a fresh preset**
+
+Run: `python generate_models_preset.py --models-dir "<models-dir>" --output "models-preset.generated.ini" --select all --profile smart --alias-style section`
+Expected: command succeeds and writes a preset with richer model-specific fields and range comments.
+
+- [ ] **Step 2: Inspect one MoE section**
+
+Look for:
+
+```ini
+; ctx-size range = ...
+; n-gpu-layers range = ...
+; n-cpu-moe range = ...
+cache-type-k = q8_0
+cache-type-v = q8_0
+```
+
+- [ ] **Step 3: Adjust implementation only if the generated output contradicts the tests**
+
+```python
+# Keep changes inside the preset heuristics helpers and comment rendering.
+```

+ 203 - 0
docs/superpowers/specs/2026-06-10-gguf-metadata-preset-design.md

@@ -0,0 +1,203 @@
+# GGUF Metadata Preset Generator Design
+
+Date: 2026-06-10
+
+## Goal
+
+Refine `generate_models_preset.py` so generated `models-preset.ini` entries use richer and more practical `llama-server` parameters, with all derivation based on GGUF metadata rather than filename heuristics.
+
+## User Constraints
+
+- Use GGUF metadata as the only model-behavior input.
+- Do not infer MoE or other model classes from filenames.
+- Keep `mmproj` detection from sibling files because it is an explicit companion artifact, not a naming heuristic.
+- Add nearby comments that show the minimum value, maximum value, and chosen value for each key parameter.
+- Do not create backup files by default.
+
+## Scope
+
+In scope:
+
+- Update `generate_models_preset.py`
+- Expand generated preset fields beyond the current minimal set
+- Derive MoE-related values from GGUF metadata
+- Emit per-parameter range comments for tunable values
+- Update or add automated tests for the new generation rules
+
+Out of scope:
+
+- Changing `start-llama-router.bat`
+- Adding filename-based fallbacks
+- Reworking router-wide launch flags
+
+## Current Problem
+
+The current generator reads some GGUF metadata, but practical preset output is still too thin:
+
+- many fields are hard-coded shared defaults
+- MoE behavior is only partially informed by metadata
+- some classification still falls back to filename keywords
+- generated output does not document legal value ranges near chosen values
+- backup behavior is on by default
+
+This makes the output weaker than the known-good single-model startup profile in `start-qwen3.6-35b-a3b.bat`.
+
+## Recommended Design
+
+### Metadata-Only Classification
+
+Classify models from metadata and explicit companion files only:
+
+- `asr_multimodal`
+  - sibling `mmproj` exists
+- `moe`
+  - architecture-specific `expert_count` is present and greater than zero
+- `dense`
+  - everything else
+
+There must be no filename keyword fallback for MoE detection or any other preset behavior.
+
+### Metadata Inputs
+
+The generator should continue reading GGUF key-value metadata directly from the header and use these keys where available:
+
+- `general.architecture`
+- `<architecture>.context_length`
+- `<architecture>.block_count`
+- `<architecture>.expert_count`
+- `<architecture>.expert_used_count`
+- `general.size_label`
+- `general.name`
+
+These values are enough to support classification plus bounded parameter generation.
+
+### Parameter Generation Rules
+
+Generate a richer field set per model section.
+
+#### Dense
+
+Dense models should receive:
+
+- `ctx-size`
+- `n-gpu-layers`
+- `threads`
+- `batch-size`
+- `ubatch-size`
+- `parallel`
+- `cache-type-k`
+- `cache-type-v`
+- `kv-unified`
+- `kv-offload`
+- `mlock`
+- `mmap`
+- `seed`
+- `flash-attn`
+
+Dense values can stay moderately permissive, but must still be derived from metadata-sensitive rules instead of one fixed default map.
+
+#### ASR / Multimodal
+
+ASR or multimodal models should stay conservative:
+
+- lower `parallel`
+- lower `batch-size`
+- lower `ctx-size` than large chat defaults
+- preserve `mmproj`
+
+#### MoE
+
+MoE models should receive:
+
+- all core fields above
+- `n-cpu-moe`
+
+`n-cpu-moe` must be emitted only for metadata-confirmed MoE models.
+
+### Bounded Value Strategy
+
+Each tunable numeric field should have a clear bounded range, with the selected value derived within that range.
+
+Required nearby comments for key fields:
+
+- `ctx-size`
+- `n-gpu-layers`
+- `n-cpu-moe` when present
+- `threads`
+- `batch-size`
+- `ubatch-size`
+- `parallel`
+
+Comment format should be simple and consistent, for example:
+
+```ini
+; ctx-size range = 512..131072; chosen = 43008
+ctx-size = 43008
+```
+
+For fields where the lower or upper bound is determined by metadata, the comment must use that metadata-derived bound.
+
+### Specific Derivation Direction
+
+The generator does not need exact benchmarking logic. It needs stable, explainable rules.
+
+Recommended direction:
+
+- `ctx-size`
+  - maximum from metadata `context_length`
+  - choose a practical default no higher than the metadata maximum
+- `n-gpu-layers`
+  - maximum from metadata `block_count`
+  - choose full offload by default when the maximum is known
+- `n-cpu-moe`
+  - maximum from metadata `block_count`
+  - chosen value derived from MoE metadata and model size pressure
+- `threads`
+  - bounded by local CPU count
+- `parallel`
+  - conservative for multimodal and MoE
+- `batch-size` and `ubatch-size`
+  - reduced for conservative profiles, especially MoE and multimodal
+- `cache-type-k` and `cache-type-v`
+  - may prefer `q8_0` for long-context or MoE models, otherwise a safer baseline
+- `mmap`
+  - default toward the more practical behavior already used in `start-qwen3.6-35b-a3b.bat`
+
+### Backup Policy
+
+Backup generation should no longer be the default behavior.
+
+Recommended implementation:
+
+- set CLI default to no backup
+- keep optional explicit `--backup` support only if trivial to preserve
+
+## Architecture
+
+Keep the current single-script architecture, but tighten responsibilities:
+
+1. GGUF metadata reader
+2. metadata-only classifier
+3. bounded preset value generators by class
+4. comment renderer for min/max/chosen annotations
+5. INI renderer
+
+## Testing
+
+Tests should cover:
+
+- MoE classification depends on metadata `expert_count`, not filename text
+- dense models never receive `n-cpu-moe`
+- multimodal models are detected from sibling `mmproj`
+- range comments are present near the key generated fields
+- backup default is disabled
+- output remains valid INI text
+
+## Risks
+
+- Some GGUF files may omit certain metadata keys; in those cases the script must use documented conservative fallback ranges without reintroducing filename heuristics.
+- `cache-type-k` and `cache-type-v` are policy choices rather than direct metadata facts, so the rules must stay simple and predictable.
+
+## Implementation Notes
+
+The workspace currently lacks normal Git repository behavior for commit operations, so this spec is written locally without a commit step.

+ 716 - 0
generate_models_preset.py

@@ -0,0 +1,716 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import math
+import os
+import re
+import shutil
+import struct
+import sys
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+
+DEFAULT_DENSE_CTX = "32768"
+DEFAULT_DENSE_GPU_LAYERS = "28"
+DEFAULT_MOE_CTX = "43008"
+DEFAULT_MOE_GPU_LAYERS = "999"
+DEFAULT_BATCH_SIZE = "2048"
+DEFAULT_MOE_BATCH_SIZE = "1024"
+DEFAULT_UBATCH_SIZE = "512"
+DEFAULT_PARALLEL = "4"
+DEFAULT_MOE_PARALLEL = "4"
+DEFAULT_ASR_PARALLEL = "1"
+DEFAULT_MIN_CTX = 512
+DEFAULT_CACHE_TYPE = "f16"
+LONG_CONTEXT_CACHE_TYPE = "q8_0"
+QUANT_PRIORITY = {
+    "Q4_K_M": 0,
+    "Q5_K_M": 1,
+    "Q6_K": 2,
+    "Q8_0": 3,
+    "Q4_K_S": 4,
+    "Q5_K_S": 5,
+    "Q4_0": 6,
+    "Q4_1": 7,
+    "Q5_0": 8,
+    "Q5_1": 9,
+    "Q3_K_M": 10,
+    "Q3_K_L": 11,
+    "Q2_K": 12,
+    "F16": 90,
+    "BF16": 91,
+}
+GGUF_MAGIC = b"GGUF"
+GGUF_TYPE_UINT8 = 0
+GGUF_TYPE_INT8 = 1
+GGUF_TYPE_UINT16 = 2
+GGUF_TYPE_INT16 = 3
+GGUF_TYPE_UINT32 = 4
+GGUF_TYPE_INT32 = 5
+GGUF_TYPE_FLOAT32 = 6
+GGUF_TYPE_BOOL = 7
+GGUF_TYPE_STRING = 8
+GGUF_TYPE_ARRAY = 9
+GGUF_TYPE_UINT64 = 10
+GGUF_TYPE_INT64 = 11
+GGUF_TYPE_FLOAT64 = 12
+GGUF_SCALAR_FORMATS = {
+    GGUF_TYPE_UINT8: "<B",
+    GGUF_TYPE_INT8: "<b",
+    GGUF_TYPE_UINT16: "<H",
+    GGUF_TYPE_INT16: "<h",
+    GGUF_TYPE_UINT32: "<I",
+    GGUF_TYPE_INT32: "<i",
+    GGUF_TYPE_FLOAT32: "<f",
+    GGUF_TYPE_BOOL: "<?",
+    GGUF_TYPE_UINT64: "<Q",
+    GGUF_TYPE_INT64: "<q",
+    GGUF_TYPE_FLOAT64: "<d",
+}
+MOE_CPU_OFFLOAD_LARGE_THRESHOLD_GIB = 20.0
+@dataclass(frozen=True)
+class ModelMetadata:
+    architecture: str | None
+    context_length: int | None
+    block_count: int | None
+    expert_count: int | None
+    expert_used_count: int | None
+    size_label: str | None
+    general_name: str | None
+    file_size_gib: float
+
+
+@dataclass(frozen=True)
+class ModelEntry:
+    publisher: str
+    model_dir: str
+    model_path: Path
+    mmproj_path: Path | None
+    quant_key: str
+    metadata: ModelMetadata
+
+
+@dataclass(frozen=True)
+class GenerateResult:
+    output_path: Path
+    backup_path: Path | None
+    entry_count: int
+
+
+@dataclass(frozen=True)
+class ValueRange:
+    minimum: int
+    maximum: int
+
+
+def is_mmproj_file(path: Path) -> bool:
+    name = path.name.lower()
+    stem = path.stem.lower()
+    return (
+        name.startswith("mmproj")
+        or ".mmproj-" in name
+        or stem.endswith(".mmproj")
+        or stem.startswith("mmproj")
+    )
+
+
+def normalize_slug(value: str) -> str:
+    lowered = value.lower()
+    normalized = re.sub(r"[^a-z0-9]+", "-", lowered)
+    normalized = re.sub(r"-{2,}", "-", normalized)
+    return normalized.strip("-") or "model"
+
+
+def detect_quant_key(filename: str) -> str:
+    upper_name = filename.upper()
+    for quant_key in QUANT_PRIORITY:
+        if quant_key in upper_name:
+            return quant_key
+    matches = re.findall(r"(Q\d(?:_[A-Z0-9]+)+|Q\d+|F16|BF16)", upper_name)
+    return matches[0] if matches else "UNKNOWN"
+
+
+def normalize_quant_key(quant_key: str) -> str:
+    return normalize_slug(quant_key)
+
+
+def quant_rank(quant_key: str) -> tuple[int, str]:
+    return (QUANT_PRIORITY.get(quant_key, 50), quant_key)
+
+
+def read_gguf_string(handle) -> str:
+    length = struct.unpack("<Q", handle.read(8))[0]
+    return handle.read(length).decode("utf-8", errors="replace")
+
+
+def read_gguf_scalar(handle, value_type: int):
+    fmt = GGUF_SCALAR_FORMATS.get(value_type)
+    if fmt is None:
+        raise ValueError(f"Unsupported GGUF scalar type: {value_type}")
+    size = struct.calcsize(fmt)
+    return struct.unpack(fmt, handle.read(size))[0]
+
+
+def read_gguf_value(handle, value_type: int):
+    if value_type == GGUF_TYPE_STRING:
+        return read_gguf_string(handle)
+    if value_type == GGUF_TYPE_ARRAY:
+        item_type = struct.unpack("<I", handle.read(4))[0]
+        item_count = struct.unpack("<Q", handle.read(8))[0]
+        return [read_gguf_value(handle, item_type) for _ in range(item_count)]
+    return read_gguf_scalar(handle, value_type)
+
+
+def parse_gguf_metadata(model_path: Path) -> dict[str, object]:
+    metadata: dict[str, object] = {}
+    with model_path.open("rb") as handle:
+        magic = handle.read(4)
+        if magic != GGUF_MAGIC:
+            raise ValueError(f"Not a GGUF file: {model_path}")
+
+        version = struct.unpack("<I", handle.read(4))[0]
+        if version not in {2, 3}:
+            raise ValueError(f"Unsupported GGUF version {version}: {model_path}")
+
+        tensor_count = struct.unpack("<Q", handle.read(8))[0]
+        metadata_count = struct.unpack("<Q", handle.read(8))[0]
+
+        for _ in range(metadata_count):
+            key = read_gguf_string(handle)
+            value_type = struct.unpack("<I", handle.read(4))[0]
+            metadata[key] = read_gguf_value(handle, value_type)
+
+        # Stop after metadata. Tensor table is not needed.
+        _ = tensor_count
+
+    return metadata
+
+
+def parse_param_billions(value: str) -> float | None:
+    match = re.search(r"(\d+(?:\.\d+)?)\s*([kmbtq])", value.strip().lower())
+    if not match:
+        return None
+    amount = float(match.group(1))
+    scale = match.group(2)
+    scale_map = {
+        "k": 1e-6,
+        "m": 1e-3,
+        "b": 1.0,
+        "t": 1e3,
+        "q": 1e6,
+    }
+    return amount * scale_map[scale]
+
+
+def estimate_param_billions_from_name(value: str) -> float | None:
+    match = re.search(r"(\d+(?:\.\d+)?)\s*b", value.lower())
+    if not match:
+        return None
+    return float(match.group(1))
+
+
+def clamp_int(value: int, minimum: int, maximum: int) -> int:
+    return max(minimum, min(value, maximum))
+
+
+def get_logical_cpu_count() -> int:
+    return os.cpu_count() or 8
+
+
+def build_core_fields(*, batch_size: str, ubatch_size: str, parallel: str, mmap: str) -> dict[str, str]:
+    return {
+        "batch-size": batch_size,
+        "ubatch-size": ubatch_size,
+        "parallel": parallel,
+        "kv-unified": "true",
+        "kv-offload": "true",
+        "mlock": "true",
+        "mmap": mmap,
+        "seed": "-1",
+        "flash-attn": "true",
+    }
+
+
+def build_sampling_defaults(family: str) -> dict[str, str]:
+    if family == "moe":
+        return {
+            "temp": "0.7",
+            "top-k": "40",
+            "top-p": "0.95",
+            "min-p": "0.0",
+            "repeat-penalty": "1.0",
+        }
+    return {
+        "temp": "0.8",
+        "top-k": "40",
+        "top-p": "0.95",
+        "min-p": "0.05",
+        "repeat-penalty": "1.0",
+    }
+
+
+def build_model_metadata(model_path: Path) -> ModelMetadata:
+    file_size_gib = model_path.stat().st_size / (1024 ** 3)
+    try:
+        raw = parse_gguf_metadata(model_path)
+    except Exception:
+        raw = {}
+
+    architecture = raw.get("general.architecture")
+    if not isinstance(architecture, str):
+        architecture = None
+
+    def get_arch_value(suffix: str) -> object | None:
+        if architecture is None:
+            return None
+        return raw.get(f"{architecture}.{suffix}")
+
+    context_length = get_arch_value("context_length")
+    block_count = get_arch_value("block_count")
+    expert_count = get_arch_value("expert_count")
+    expert_used_count = get_arch_value("expert_used_count")
+    size_label = raw.get("general.size_label")
+    general_name = raw.get("general.name")
+
+    return ModelMetadata(
+        architecture=architecture,
+        context_length=context_length if isinstance(context_length, int) else None,
+        block_count=block_count if isinstance(block_count, int) else None,
+        expert_count=expert_count if isinstance(expert_count, int) else None,
+        expert_used_count=expert_used_count if isinstance(expert_used_count, int) else None,
+        size_label=size_label if isinstance(size_label, str) else None,
+        general_name=general_name if isinstance(general_name, str) else None,
+        file_size_gib=file_size_gib,
+    )
+
+
+def classify_model_family(entry: ModelEntry) -> str:
+    architecture = (entry.metadata.architecture or "").lower()
+    if entry.metadata.expert_count and entry.metadata.expert_count > 0:
+        return "moe"
+    if "moe" in architecture:
+        return "moe"
+    if entry.mmproj_path is not None:
+        return "asr_multimodal"
+    return "dense"
+
+
+def classify_moe_tier(entry: ModelEntry) -> str:
+    metadata = entry.metadata
+    score = 0
+    if metadata.expert_count:
+        score += 2
+    if metadata.expert_used_count and metadata.expert_used_count >= 4:
+        score += 1
+    if metadata.file_size_gib >= MOE_CPU_OFFLOAD_LARGE_THRESHOLD_GIB:
+        score += 2
+    elif metadata.file_size_gib >= 12.0:
+        score += 1
+    if metadata.context_length and metadata.context_length >= 131072:
+        score += 2
+    elif metadata.context_length and metadata.context_length >= 65536:
+        score += 1
+    if metadata.block_count and metadata.block_count >= 80:
+        score += 1
+
+    if score >= 5:
+        return "large"
+    if score >= 3:
+        return "medium"
+    return "small"
+
+
+def choose_gpu_layers(entry: ModelEntry, fallback: str) -> str:
+    block_count = entry.metadata.block_count or 0
+    if block_count > 0:
+        return str(block_count)
+    return fallback
+
+
+def choose_context_size(entry: ModelEntry, family: str) -> str:
+    max_context = entry.metadata.context_length or 0
+    if max_context <= 0:
+        defaults = {
+            "dense": DEFAULT_DENSE_CTX,
+            "asr_multimodal": "8192",
+            "moe": DEFAULT_MOE_CTX,
+        }
+        return defaults[family]
+
+    target_map = {
+        "dense": int(DEFAULT_DENSE_CTX),
+        "asr_multimodal": 8192,
+        "moe": int(DEFAULT_MOE_CTX),
+    }
+    target = min(max_context, target_map[family])
+    chosen = clamp_int(target, 1 if max_context < DEFAULT_MIN_CTX else DEFAULT_MIN_CTX, max_context)
+    return str(chosen)
+
+
+def choose_cache_type(entry: ModelEntry, family: str) -> str:
+    context_length = entry.metadata.context_length or 0
+    if family == "moe" or context_length >= 65536:
+        return LONG_CONTEXT_CACHE_TYPE
+    return DEFAULT_CACHE_TYPE
+
+
+def choose_moe_cpu_layers(entry: ModelEntry) -> str | None:
+    block_count = entry.metadata.block_count or 0
+    if block_count <= 0:
+        return None
+    return "0"
+
+
+def build_dense_values(profile: str) -> dict[str, str]:
+    _ = profile
+    values = build_core_fields(
+        batch_size=DEFAULT_BATCH_SIZE,
+        ubatch_size=DEFAULT_UBATCH_SIZE,
+        parallel=DEFAULT_PARALLEL,
+        mmap="true",
+    )
+    values["n-gpu-layers"] = DEFAULT_DENSE_GPU_LAYERS
+    values["threads"] = str(default_thread_pool_size())
+    values["cache-type-k"] = DEFAULT_CACHE_TYPE
+    values["cache-type-v"] = DEFAULT_CACHE_TYPE
+    values.update(build_sampling_defaults("dense"))
+    return values
+
+
+def build_asr_multimodal_values(entry: ModelEntry, profile: str) -> dict[str, str]:
+    _ = profile
+    values = build_core_fields(
+        batch_size=DEFAULT_MOE_BATCH_SIZE,
+        ubatch_size=DEFAULT_UBATCH_SIZE,
+        parallel=DEFAULT_ASR_PARALLEL,
+        mmap="true",
+    )
+    values["n-gpu-layers"] = choose_gpu_layers(entry, DEFAULT_DENSE_GPU_LAYERS)
+    values["threads"] = str(default_thread_pool_size())
+    values["ctx-size"] = choose_context_size(entry, "asr_multimodal")
+    cache_type = choose_cache_type(entry, "asr_multimodal")
+    values["cache-type-k"] = cache_type
+    values["cache-type-v"] = cache_type
+    values.update(build_sampling_defaults("dense"))
+    return values
+
+
+def build_moe_values(entry: ModelEntry, profile: str) -> dict[str, str]:
+    _ = profile
+    values = build_core_fields(
+        batch_size=DEFAULT_BATCH_SIZE,
+        ubatch_size=DEFAULT_UBATCH_SIZE,
+        parallel=DEFAULT_MOE_PARALLEL,
+        mmap="true",
+    )
+    values["ctx-size"] = choose_context_size(entry, "moe")
+    values["n-gpu-layers"] = choose_gpu_layers(entry, DEFAULT_MOE_GPU_LAYERS)
+    values["threads"] = str(default_thread_pool_size())
+    values["cache-type-k"] = LONG_CONTEXT_CACHE_TYPE
+    values["cache-type-v"] = LONG_CONTEXT_CACHE_TYPE
+    values.update(build_sampling_defaults("moe"))
+    n_cpu_moe = choose_moe_cpu_layers(entry)
+    if n_cpu_moe is not None:
+        values["n-cpu-moe"] = n_cpu_moe
+    return values
+
+
+def default_thread_pool_size() -> int:
+    logical_cpu_count = get_logical_cpu_count()
+    return max(1, math.floor(logical_cpu_count * 0.7))
+
+
+def scan_models(models_dir: Path, select_mode: str) -> list[ModelEntry]:
+    models_dir = Path(models_dir)
+    if not models_dir.is_dir():
+        raise FileNotFoundError(f"Models directory not found: {models_dir}")
+
+    grouped: dict[tuple[str, str], list[ModelEntry]] = {}
+    for path in sorted(models_dir.rglob("*.gguf")):
+        if is_mmproj_file(path):
+            continue
+
+        relative_parts = path.relative_to(models_dir).parts
+        if len(relative_parts) < 3:
+            publisher = relative_parts[0] if relative_parts else "unknown"
+            model_dir = path.parent.name
+        else:
+            publisher = relative_parts[0]
+            model_dir = relative_parts[1]
+
+        mmproj_candidates = sorted(candidate for candidate in path.parent.glob("*.gguf") if is_mmproj_file(candidate))
+        mmproj_path = mmproj_candidates[0] if mmproj_candidates else None
+
+        entry = ModelEntry(
+            publisher=publisher,
+            model_dir=model_dir,
+            model_path=path,
+            mmproj_path=mmproj_path,
+            quant_key=detect_quant_key(path.name),
+            metadata=build_model_metadata(path),
+        )
+        grouped.setdefault((publisher, model_dir), []).append(entry)
+
+    selected: list[ModelEntry] = []
+    for _, entries in sorted(grouped.items()):
+        if select_mode == "all":
+            selected.extend(sorted(entries, key=lambda item: item.model_path.name.lower()))
+        elif select_mode == "best":
+            best_entry = min(
+                entries,
+                key=lambda item: (quant_rank(item.quant_key), item.model_path.name.lower()),
+            )
+            selected.append(best_entry)
+        else:
+            raise ValueError(f"Unsupported select mode: {select_mode}")
+
+    if not selected:
+        raise ValueError(f"No model GGUF files found under {models_dir}")
+    return selected
+
+
+def build_preset_values(entry: ModelEntry, profile: str) -> dict[str, str]:
+    if profile not in {"conservative", "smart"}:
+        raise ValueError(f"Unsupported profile: {profile}")
+
+    family = classify_model_family(entry)
+    if family == "asr_multimodal":
+        return build_asr_multimodal_values(entry, profile)
+    if family == "moe":
+        return build_moe_values(entry, profile)
+    values = build_dense_values(profile)
+    values["ctx-size"] = choose_context_size(entry, "dense")
+    values["n-gpu-layers"] = choose_gpu_layers(entry, values["n-gpu-layers"])
+    cache_type = choose_cache_type(entry, "dense")
+    values["cache-type-k"] = cache_type
+    values["cache-type-v"] = cache_type
+    return values
+
+
+def build_value_ranges(entry: ModelEntry, values: dict[str, str]) -> dict[str, ValueRange]:
+    ranges: dict[str, ValueRange] = {}
+    context_length = entry.metadata.context_length or 0
+    block_count = entry.metadata.block_count or 0
+    thread_count = default_thread_pool_size()
+
+    if "ctx-size" in values and context_length > 0:
+        ranges["ctx-size"] = ValueRange(minimum=min(DEFAULT_MIN_CTX, context_length), maximum=context_length)
+    if "n-gpu-layers" in values and block_count > 0:
+        ranges["n-gpu-layers"] = ValueRange(minimum=0, maximum=block_count)
+    if "threads" in values:
+        ranges["threads"] = ValueRange(minimum=1, maximum=get_logical_cpu_count() or thread_count)
+    if "batch-size" in values:
+        ranges["batch-size"] = ValueRange(minimum=32, maximum=int(values["batch-size"]))
+    if "ubatch-size" in values:
+        ranges["ubatch-size"] = ValueRange(minimum=32, maximum=int(values["batch-size"]))
+    if "parallel" in values:
+        ranges["parallel"] = ValueRange(minimum=1, maximum=int(values["parallel"]))
+    if "n-cpu-moe" in values and block_count > 0:
+        ranges["n-cpu-moe"] = ValueRange(minimum=0, maximum=block_count)
+
+    return ranges
+
+
+def format_value_comment(key: str, value: str, ranges: dict[str, ValueRange]) -> str | None:
+    value_range = ranges.get(key)
+    if value_range is None:
+        return None
+    return f"; {key} range = {value_range.minimum}..{value_range.maximum}; chosen = {value}"
+
+
+def make_alias(
+    publisher: str,
+    model_dir: str,
+    model_file: str,
+    alias_style: str,
+    used_aliases: set[str],
+) -> str:
+    if alias_style == "filename":
+        base = Path(model_file).stem
+    elif alias_style == "section":
+        base = f"{publisher}/{model_dir}:{detect_quant_key(model_file)}"
+    elif alias_style == "short":
+        base = Path(model_file).stem
+    else:
+        raise ValueError(f"Unsupported alias style: {alias_style}")
+
+    if alias_style == "section":
+        alias = base
+    else:
+        alias = normalize_slug(base)
+    if alias_style == "short":
+        alias = re.sub(r"-gguf$", "", alias)
+
+    unique_alias = alias
+    suffix = 2
+    while unique_alias in used_aliases:
+        if alias_style == "section":
+            unique_alias = f"{alias}-{suffix}"
+        else:
+            unique_alias = f"{alias}-{suffix}"
+        suffix += 1
+    used_aliases.add(unique_alias)
+    return unique_alias
+
+
+def render_ini(
+    entries: list[ModelEntry],
+    models_dir: Path,
+    select_mode: str,
+    profile: str,
+    alias_style: str,
+    backup_path: Path | None,
+) -> str:
+    generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+    lines = [
+        "; Auto-generated llama.cpp models preset",
+        f"; generated-at = {generated_at}",
+        f"; models-dir = {models_dir}",
+        f"; select = {select_mode}",
+        f"; profile = {profile}",
+        f"; alias-style = {alias_style}",
+        f"; backup = {backup_path if backup_path else 'none'}",
+        "",
+        "version = 1",
+        "",
+    ]
+
+    used_aliases: set[str] = set()
+    for entry in entries:
+        alias = make_alias(
+            publisher=entry.publisher,
+            model_dir=entry.model_dir,
+            model_file=entry.model_path.name,
+            alias_style=alias_style,
+            used_aliases=used_aliases,
+        )
+        values = build_preset_values(entry=entry, profile=profile)
+
+        lines.append(f"[{alias}]")
+        lines.append(f"model = {entry.model_path}")
+        if entry.mmproj_path is not None:
+            lines.append(f"mmproj = {entry.mmproj_path}")
+        lines.append(f"alias = {alias}")
+        ranges = build_value_ranges(entry, values)
+        ordered_keys = [
+            "ctx-size",
+            "n-gpu-layers",
+            "threads",
+            "batch-size",
+            "ubatch-size",
+            "parallel",
+            "temp",
+            "top-k",
+            "top-p",
+            "min-p",
+            "repeat-penalty",
+            "cache-type-k",
+            "cache-type-v",
+            "kv-unified",
+            "kv-offload",
+            "mlock",
+            "mmap",
+            "seed",
+            "flash-attn",
+        ]
+        optional_keys = ["n-cpu-moe", "cpu-moe"]
+        sampling_keys = {"temp", "top-k", "top-p", "min-p", "repeat-penalty"}
+        sampling_comment_written = False
+        for key in ordered_keys:
+            if key in sampling_keys and not sampling_comment_written:
+                lines.append("; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived")
+                sampling_comment_written = True
+            comment = format_value_comment(key, values[key], ranges)
+            if comment is not None:
+                lines.append(comment)
+            lines.append(f"{key} = {values[key]}")
+        for key in optional_keys:
+            if key in values:
+                comment = format_value_comment(key, values[key], ranges)
+                if comment is not None:
+                    lines.append(comment)
+                lines.append(f"{key} = {values[key]}")
+        lines.append("")
+
+    return "\n".join(lines).rstrip() + "\n"
+
+
+def maybe_backup_file(output_path: Path, backup: bool) -> Path | None:
+    if not backup or not output_path.exists():
+        return None
+    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+    backup_path = output_path.with_name(f"{output_path.stem}.{timestamp}.bak{output_path.suffix}")
+    shutil.copy2(output_path, backup_path)
+    return backup_path
+
+
+def generate_preset_file(
+    models_dir: Path,
+    output_path: Path,
+    select_mode: str,
+    profile: str,
+    alias_style: str,
+    backup: bool,
+) -> GenerateResult:
+    entries = scan_models(models_dir=models_dir, select_mode=select_mode)
+    output_path = Path(output_path)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    backup_path = maybe_backup_file(output_path=output_path, backup=backup)
+    ini_text = render_ini(
+        entries=entries,
+        models_dir=Path(models_dir),
+        select_mode=select_mode,
+        profile=profile,
+        alias_style=alias_style,
+        backup_path=backup_path,
+    )
+    output_path.write_text(ini_text, encoding="utf-8")
+    return GenerateResult(
+        output_path=output_path,
+        backup_path=backup_path,
+        entry_count=len(entries),
+    )
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description="Scan an LM Studio-style models directory and generate models-preset.ini"
+    )
+    parser.add_argument("--models-dir", required=True, help="LM Studio-style models root directory")
+    parser.add_argument("--output", default="models-preset.ini", help="Output INI file path")
+    parser.add_argument("--select", choices=["all", "best"], default="all")
+    parser.add_argument("--profile", choices=["conservative", "smart"], default="smart")
+    parser.add_argument("--alias-style", choices=["filename", "section", "short"], default="section")
+    parser.add_argument("--backup", dest="backup", action="store_true", default=False)
+    parser.add_argument("--no-backup", dest="backup", action="store_false")
+    return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = build_parser()
+    args = parser.parse_args(argv)
+
+    try:
+        result = generate_preset_file(
+            models_dir=Path(args.models_dir),
+            output_path=Path(args.output),
+            select_mode=args.select,
+            profile=args.profile,
+            alias_style=args.alias_style,
+            backup=args.backup,
+        )
+    except Exception as exc:
+        print(f"ERROR: {exc}", file=sys.stderr)
+        return 1
+
+    print(f"Generated {result.entry_count} model preset section(s) at: {result.output_path}")
+    if result.backup_path is not None:
+        print(f"Backup created: {result.backup_path}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 794 - 0
models-preset.ini

@@ -0,0 +1,794 @@
+; Auto-generated llama.cpp models preset
+; generated-at = 2026-07-06 15:00:32
+; models-dir = E:\LMStudio\.lmstudio\models
+; select = all
+; profile = smart
+; alias-style = section
+; backup = none
+
+version = 1
+
+[HauhauCS/Gemma4-26B-A4B-Uncensored-HauhauCS-Balanced-GGUF:Q4_K_P]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Gemma4-26B-A4B-Uncensored-HauhauCS-Balanced-GGUF\Gemma4-26B-A4B-Uncensored-HauhauCS-Balanced-Q4_K_P.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Gemma4-26B-A4B-Uncensored-HauhauCS-Balanced-GGUF\mmproj-Gemma4-26B-A4B-Uncensored-HauhauCS-Balanced-f16.gguf
+alias = HauhauCS/Gemma4-26B-A4B-Uncensored-HauhauCS-Balanced-GGUF:Q4_K_P
+; ctx-size range = 512..262144; chosen = 43008
+ctx-size = 43008
+; n-gpu-layers range = 0..30; chosen = 30
+n-gpu-layers = 30
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.7
+top-k = 40
+top-p = 0.95
+min-p = 0.0
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+; n-cpu-moe range = 0..30; chosen = 0
+n-cpu-moe = 0
+
+[HauhauCS/Qwen3.5-35B-A3B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-35B-A3B-Uncensored-HauhauCS-Aggressive-GGUF\Qwen3.5-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-35B-A3B-Uncensored-HauhauCS-Aggressive-GGUF\mmproj-Qwen3.5-35B-A3B-Uncensored-HauhauCS-Aggressive-f16.gguf
+alias = HauhauCS/Qwen3.5-35B-A3B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 43008
+ctx-size = 43008
+; n-gpu-layers range = 0..40; chosen = 40
+n-gpu-layers = 40
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.7
+top-k = 40
+top-p = 0.95
+min-p = 0.0
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+; n-cpu-moe range = 0..40; chosen = 0
+n-cpu-moe = 0
+
+[HauhauCS/Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-GGUF\Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-GGUF\mmproj-Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-BF16.gguf
+alias = HauhauCS/Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..32; chosen = 32
+n-gpu-layers = 32
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[HauhauCS/Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF\Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-NoThink-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF\mmproj-Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-BF16.gguf
+alias = HauhauCS/Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..32; chosen = 32
+n-gpu-layers = 32
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-GGUF\Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-GGUF\mmproj-Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-BF16.gguf
+alias = HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..32; chosen = 32
+n-gpu-layers = 32
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF\Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-NoThink-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF\mmproj-Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-BF16.gguf
+alias = HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..32; chosen = 32
+n-gpu-layers = 32
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[HauhauCS/Qwen3.6-27B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_P]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.6-27B-Uncensored-HauhauCS-Aggressive-GGUF\Qwen3.6-27B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.6-27B-Uncensored-HauhauCS-Aggressive-GGUF\mmproj-Qwen3.6-27B-Uncensored-HauhauCS-Aggressive-f16.gguf
+alias = HauhauCS/Qwen3.6-27B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_P
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..64; chosen = 64
+n-gpu-layers = 64
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[HauhauCS/Qwen3.6-27B-Uncensored-HauhauCS-Balanced-GGUF:Q4_K_P]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.6-27B-Uncensored-HauhauCS-Balanced-GGUF\Qwen3.6-27B-Uncensored-HauhauCS-Balanced-Q4_K_P.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.6-27B-Uncensored-HauhauCS-Balanced-GGUF\mmproj-Qwen3.6-27B-Uncensored-HauhauCS-Balanced-f16.gguf
+alias = HauhauCS/Qwen3.6-27B-Uncensored-HauhauCS-Balanced-GGUF:Q4_K_P
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..64; chosen = 64
+n-gpu-layers = 64
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-GGUF\Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-GGUF\mmproj-Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-f16.gguf
+alias = HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 43008
+ctx-size = 43008
+; n-gpu-layers range = 0..40; chosen = 40
+n-gpu-layers = 40
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.7
+top-k = 40
+top-p = 0.95
+min-p = 0.0
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+; n-cpu-moe range = 0..40; chosen = 0
+n-cpu-moe = 0
+
+[HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF\Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-NoThink-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF\mmproj-Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-f16.gguf
+alias = HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-NoThink-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 43008
+ctx-size = 43008
+; n-gpu-layers range = 0..40; chosen = 40
+n-gpu-layers = 40
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.7
+top-k = 40
+top-p = 0.95
+min-p = 0.0
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+; n-cpu-moe range = 0..40; chosen = 0
+n-cpu-moe = 0
+
+[HauhauCS/Qwen3VL-8B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\HauhauCS\Qwen3VL-8B-Uncensored-HauhauCS-Aggressive-GGUF\Qwen3VL-8B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf
+alias = HauhauCS/Qwen3VL-8B-Uncensored-HauhauCS-Aggressive-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 32768
+ctx-size = 32768
+; n-gpu-layers range = 0..36; chosen = 36
+n-gpu-layers = 36
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[Tencent/HY-MT1.5-1.8B-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\Tencent\HY-MT1.5-1.8B-GGUF\HY-MT1.5-1.8B-Q4_K_M.gguf
+alias = Tencent/HY-MT1.5-1.8B-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 32768
+ctx-size = 32768
+; n-gpu-layers range = 0..32; chosen = 32
+n-gpu-layers = 32
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[ggml-org/Qwen3-ASR-1.7B-GGUF:Q8_0]
+model = E:\LMStudio\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\Qwen3-ASR-1.7B-Q8_0.gguf
+mmproj = E:\LMStudio\.lmstudio\models\ggml-org\Qwen3-ASR-1.7B-GGUF\mmproj-Qwen3-ASR-1.7B-bf16.gguf
+alias = ggml-org/Qwen3-ASR-1.7B-GGUF:Q8_0
+; ctx-size range = 512..65536; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..28; chosen = 28
+n-gpu-layers = 28
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[ggml-org/SmolVLM2-500M-Video-Instruct-GGUF:Q8_0]
+model = E:\LMStudio\.lmstudio\models\ggml-org\SmolVLM2-500M-Video-Instruct-GGUF\SmolVLM2-500M-Video-Instruct-Q8_0.gguf
+mmproj = E:\LMStudio\.lmstudio\models\ggml-org\SmolVLM2-500M-Video-Instruct-GGUF\mmproj-SmolVLM2-500M-Video-Instruct-f16.gguf
+alias = ggml-org/SmolVLM2-500M-Video-Instruct-GGUF:Q8_0
+; ctx-size range = 512..8192; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..32; chosen = 32
+n-gpu-layers = 32
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = f16
+cache-type-v = f16
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[lmstudio-community/Qwen3-8B-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3-8B-GGUF\Qwen3-8B-Q4_K_M.gguf
+alias = lmstudio-community/Qwen3-8B-GGUF:Q4_K_M
+; ctx-size range = 512..32768; chosen = 32768
+ctx-size = 32768
+; n-gpu-layers range = 0..36; chosen = 36
+n-gpu-layers = 36
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = f16
+cache-type-v = f16
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[lmstudio-community/Qwen3.5-35B-A3B-GGUF:Q6_K]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3.5-35B-A3B-GGUF\Qwen3.5-35B-A3B-Q6_K.gguf
+mmproj = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3.5-35B-A3B-GGUF\mmproj-Qwen3.5-35B-A3B-BF16.gguf
+alias = lmstudio-community/Qwen3.5-35B-A3B-GGUF:Q6_K
+; ctx-size range = 512..262144; chosen = 43008
+ctx-size = 43008
+; n-gpu-layers range = 0..40; chosen = 40
+n-gpu-layers = 40
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.7
+top-k = 40
+top-p = 0.95
+min-p = 0.0
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+; n-cpu-moe range = 0..40; chosen = 0
+n-cpu-moe = 0
+
+[lmstudio-community/Qwen3.5-4B-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3.5-4B-GGUF\Qwen3.5-4B-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3.5-4B-GGUF\mmproj-Qwen3.5-4B-BF16.gguf
+alias = lmstudio-community/Qwen3.5-4B-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..32; chosen = 32
+n-gpu-layers = 32
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[lmstudio-community/Qwen3.5-9B-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3.5-9B-GGUF\Qwen3.5-9B-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3.5-9B-GGUF\mmproj-Qwen3.5-9B-BF16.gguf
+alias = lmstudio-community/Qwen3.5-9B-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..32; chosen = 32
+n-gpu-layers = 32
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[lmstudio-community/Qwen3.6-35B-A3B-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3.6-35B-A3B-GGUF\Qwen3.6-35B-A3B-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\lmstudio-community\Qwen3.6-35B-A3B-GGUF\mmproj-Qwen3.6-35B-A3B-BF16.gguf
+alias = lmstudio-community/Qwen3.6-35B-A3B-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 43008
+ctx-size = 43008
+; n-gpu-layers range = 0..40; chosen = 40
+n-gpu-layers = 40
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.7
+top-k = 40
+top-p = 0.95
+min-p = 0.0
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+; n-cpu-moe range = 0..40; chosen = 0
+n-cpu-moe = 0
+
+[lmstudio-community/gemma-3-12b-it-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-3-12b-it-GGUF\gemma-3-12b-it-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-3-12b-it-GGUF\mmproj-model-f16.gguf
+alias = lmstudio-community/gemma-3-12b-it-GGUF:Q4_K_M
+; ctx-size range = 512..131072; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..48; chosen = 48
+n-gpu-layers = 48
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[lmstudio-community/gemma-3-4b-it-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-3-4b-it-GGUF\gemma-3-4b-it-Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-3-4b-it-GGUF\mmproj-model-f16.gguf
+alias = lmstudio-community/gemma-3-4b-it-GGUF:Q4_K_M
+; ctx-size range = 512..131072; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..34; chosen = 34
+n-gpu-layers = 34
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[lmstudio-community/gemma-3n-E4B-it-text-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-3n-E4B-it-text-GGUF\gemma-3n-E4B-it-Q4_K_M.gguf
+alias = lmstudio-community/gemma-3n-E4B-it-text-GGUF:Q4_K_M
+; ctx-size range = 512..32768; chosen = 32768
+ctx-size = 32768
+; n-gpu-layers range = 0..35; chosen = 35
+n-gpu-layers = 35
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = f16
+cache-type-v = f16
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[lmstudio-community/gemma-4-12B-it-QAT-GGUF:Q4_0]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-4-12B-it-QAT-GGUF\gemma-4-12B-it-QAT-Q4_0.gguf
+mmproj = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-4-12B-it-QAT-GGUF\mmproj-gemma-4-12B-it-QAT-BF16.gguf
+alias = lmstudio-community/gemma-4-12B-it-QAT-GGUF:Q4_0
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..48; chosen = 48
+n-gpu-layers = 48
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+
+[lmstudio-community/gemma-4-26B-A4B-it-GGUF:Q6_K]
+model = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-4-26B-A4B-it-GGUF\gemma-4-26B-A4B-it-Q6_K.gguf
+mmproj = E:\LMStudio\.lmstudio\models\lmstudio-community\gemma-4-26B-A4B-it-GGUF\mmproj-gemma-4-26B-A4B-it-BF16.gguf
+alias = lmstudio-community/gemma-4-26B-A4B-it-GGUF:Q6_K
+; ctx-size range = 512..262144; chosen = 43008
+ctx-size = 43008
+; n-gpu-layers range = 0..30; chosen = 30
+n-gpu-layers = 30
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..2048; chosen = 2048
+batch-size = 2048
+; ubatch-size range = 32..2048; chosen = 512
+ubatch-size = 512
+; parallel range = 1..4; chosen = 4
+parallel = 4
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.7
+top-k = 40
+top-p = 0.95
+min-p = 0.0
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true
+; n-cpu-moe range = 0..30; chosen = 0
+n-cpu-moe = 0
+
+[mradermacher/Qwen3-VL-8B-Abliterated-Caption-GGUF:Q4_K_M]
+model = E:\LMStudio\.lmstudio\models\mradermacher\Qwen3-VL-8B-Abliterated-Caption-GGUF\Qwen3-VL-8B-Abliterated-Caption-it.Q4_K_M.gguf
+mmproj = E:\LMStudio\.lmstudio\models\mradermacher\Qwen3-VL-8B-Abliterated-Caption-GGUF\Qwen3-VL-8B-Abliterated-Caption-it.mmproj-Q8_0.gguf
+alias = mradermacher/Qwen3-VL-8B-Abliterated-Caption-GGUF:Q4_K_M
+; ctx-size range = 512..262144; chosen = 8192
+ctx-size = 8192
+; n-gpu-layers range = 0..36; chosen = 36
+n-gpu-layers = 36
+; threads range = 1..20; chosen = 14
+threads = 14
+; batch-size range = 32..1024; chosen = 1024
+batch-size = 1024
+; ubatch-size range = 32..1024; chosen = 512
+ubatch-size = 512
+; parallel range = 1..1; chosen = 1
+parallel = 1
+; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived
+temp = 0.8
+top-k = 40
+top-p = 0.95
+min-p = 0.05
+repeat-penalty = 1.0
+cache-type-k = q8_0
+cache-type-v = q8_0
+kv-unified = true
+kv-offload = true
+mlock = true
+mmap = true
+seed = -1
+flash-attn = true

+ 122 - 0
start-llama-router.ps1

@@ -0,0 +1,122 @@
+[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
+[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
+$OutputEncoding = [Console]::OutputEncoding
+$env:PYTHONIOENCODING = 'utf-8'
+$env:PYTHONUTF8 = '1'
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$ServerExe = 'llama-server.exe'
+
+# === 基础路径 / Base paths ===
+$LlamaBin = 'D:\llama'
+$ModelsDir = 'E:\LMStudio\.lmstudio\models'
+$Preset = Join-Path $ScriptDir 'models-preset.ini'
+$Python = 'python'
+
+# === 网络 / Network ===
+$LLAMA_Host = '127.0.0.1'
+$Port = 18003
+$Timeout = ''
+$ThreadsHttp = ''
+
+# === Router 行为 / Router behavior ===
+$Ttl = 300
+$ModelsMax = 1
+$RegeneratePreset = $true
+$ApiKey = ''
+$ApiKeyFile = ''
+$CacheRam = ''
+$EnableMetrics = $false
+$EnableUi = $false
+$NoUi = $false
+$SslCertFile = ''
+$SslKeyFile = ''
+
+$LlamaServer = Join-Path $LlamaBin $ServerExe
+if (-not (Test-Path $LlamaServer)) {
+    throw "llama-server.exe not found: $LlamaServer"
+}
+
+Write-Host '============================================'
+Write-Host ' llama.cpp Router Server'
+Write-Host ' LM Studio-like router mode with ASR support'
+Write-Host '============================================'
+Write-Host ''
+Write-Host " llama-server: $LlamaServer"
+Write-Host " Models dir:   $ModelsDir"
+Write-Host " Preset file:  $Preset"
+Write-Host " Host:         $LLAMA_Host"
+Write-Host " Port:         $Port"
+Write-Host " TTL:          ${Ttl}s"
+Write-Host " Models max:   $ModelsMax"
+Write-Host ''
+
+if ($RegeneratePreset) {
+    Write-Host 'Regenerating models preset / 重新生成模型预设...'
+    & $Python (Join-Path $ScriptDir 'generate_models_preset.py') `
+        --models-dir $ModelsDir `
+        --output $Preset `
+        --select all `
+        --profile smart `
+        --alias-style section
+    if ($LASTEXITCODE -ne 0) {
+        throw 'Failed to generate models-preset.ini'
+    }
+    Write-Host ''
+}
+
+$Args = @(
+    '--models-dir', $ModelsDir,
+    '--models-preset', $Preset,
+    '--models-autoload',
+    '--models-max', "$ModelsMax",
+    '--sleep-idle-seconds', "$Ttl",
+    '--cache-idle-slots',
+    '--host', $LLAMA_Host,
+    '--port', "$Port"
+)
+
+if ($ApiKey) {
+    $Args += @('--api-key', $ApiKey)
+}
+
+if ($ApiKeyFile) {
+    $Args += @('--api-key-file', $ApiKeyFile)
+}
+
+if ($Timeout) {
+    $Args += @('--timeout', "$Timeout")
+}
+
+if ($CacheRam) {
+    $Args += @('--cache-ram', "$CacheRam")
+}
+
+if ($ThreadsHttp) {
+    $Args += @('--threads-http', "$ThreadsHttp")
+}
+
+if ($EnableMetrics) {
+    $Args += '--metrics'
+}
+
+if ($EnableUi) {
+    $Args += '--ui'
+}
+
+if ($NoUi) {
+    $Args += '--no-ui'
+}
+
+if ($SslCertFile) {
+    $Args += @('--ssl-cert-file', $SslCertFile)
+}
+
+if ($SslKeyFile) {
+    $Args += @('--ssl-key-file', $SslKeyFile)
+}
+
+& $LlamaServer @Args
+exit $LASTEXITCODE

+ 245 - 0
tests/test_generate_models_preset.py

@@ -0,0 +1,245 @@
+import re
+import unittest
+from unittest.mock import patch
+from pathlib import Path
+
+from generate_models_preset import (
+    ModelEntry,
+    ModelMetadata,
+    build_parser,
+    build_preset_values,
+    classify_model_family,
+    make_alias,
+    render_ini,
+)
+
+
+def make_entry(
+    *,
+    filename: str = "model.gguf",
+    architecture: str | None = "llama",
+    context_length: int | None = 32768,
+    block_count: int | None = 40,
+    expert_count: int | None = None,
+    expert_used_count: int | None = None,
+    mmproj: bool = False,
+) -> ModelEntry:
+    return ModelEntry(
+        publisher="publisher",
+        model_dir="model-dir",
+        model_path=Path(r"C:\models") / filename,
+        mmproj_path=(Path(r"C:\models") / "mmproj.gguf") if mmproj else None,
+        quant_key="Q4_K_M",
+        metadata=ModelMetadata(
+            architecture=architecture,
+            context_length=context_length,
+            block_count=block_count,
+            expert_count=expert_count,
+            expert_used_count=expert_used_count,
+            size_label=None,
+            general_name=None,
+            file_size_gib=24.0,
+        ),
+    )
+
+
+class ClassificationTests(unittest.TestCase):
+    def test_moe_detection_uses_metadata_not_filename(self):
+        entry = make_entry(filename="mixtral-8x7b.gguf", architecture="llama")
+        self.assertEqual(classify_model_family(entry), "dense")
+
+    def test_moe_detection_accepts_moe_architecture_metadata(self):
+        entry = make_entry(architecture="qwen35moe")
+        self.assertEqual(classify_model_family(entry), "moe")
+
+    def test_moe_metadata_takes_priority_over_mmproj(self):
+        entry = make_entry(
+            filename="plain-model.gguf",
+            architecture="qwen35moe",
+            expert_count=128,
+            expert_used_count=8,
+            mmproj=True,
+        )
+        self.assertEqual(classify_model_family(entry), "moe")
+
+
+class PresetValueTests(unittest.TestCase):
+    def test_dense_values_keep_gpu_layers_field(self):
+        entry = make_entry(
+            filename="plain-model.gguf",
+            architecture="llama",
+            context_length=32768,
+            block_count=40,
+        )
+
+        with patch("generate_models_preset.os.cpu_count", return_value=20):
+            values = build_preset_values(entry, "smart")
+
+        self.assertEqual(values["ctx-size"], "32768")
+        self.assertEqual(values["n-gpu-layers"], "40")
+        self.assertEqual(values["parallel"], "4")
+        self.assertEqual(values["threads"], "14")
+
+    def test_moe_values_include_broader_runtime_fields(self):
+        entry = make_entry(
+            filename="plain-model.gguf",
+            architecture="qwen35moe",
+            context_length=131072,
+            block_count=99,
+            expert_count=128,
+            expert_used_count=8,
+        )
+
+        with patch("generate_models_preset.os.cpu_count", return_value=20):
+            values = build_preset_values(entry, "smart")
+
+        self.assertEqual(values["ctx-size"], "43008")
+        self.assertEqual(values["n-gpu-layers"], "99")
+        self.assertEqual(values["parallel"], "4")
+        self.assertEqual(values["batch-size"], "2048")
+        self.assertEqual(values["ubatch-size"], "512")
+        self.assertEqual(values["n-cpu-moe"], "0")
+        self.assertEqual(values["cache-type-k"], "q8_0")
+        self.assertEqual(values["cache-type-v"], "q8_0")
+        self.assertEqual(values["mmap"], "true")
+        self.assertEqual(values["threads"], "14")
+        self.assertEqual(values["temp"], "0.7")
+        self.assertEqual(values["top-k"], "40")
+        self.assertEqual(values["top-p"], "0.95")
+        self.assertEqual(values["min-p"], "0.0")
+        self.assertEqual(values["repeat-penalty"], "1.0")
+
+    def test_moe_with_mmproj_still_gets_n_cpu_moe(self):
+        entry = make_entry(
+            filename="plain-model.gguf",
+            architecture="qwen35moe",
+            context_length=131072,
+            block_count=99,
+            expert_count=128,
+            expert_used_count=8,
+            mmproj=True,
+        )
+
+        with patch("generate_models_preset.os.cpu_count", return_value=20):
+            values = build_preset_values(entry, "smart")
+
+        self.assertEqual(values["n-cpu-moe"], "0")
+        self.assertEqual(values["batch-size"], "2048")
+
+    def test_dense_models_get_sampling_defaults(self):
+        entry = make_entry(
+            filename="plain-model.gguf",
+            architecture="llama",
+            context_length=32768,
+            block_count=40,
+        )
+
+        with patch("generate_models_preset.os.cpu_count", return_value=20):
+            values = build_preset_values(entry, "smart")
+
+        self.assertEqual(values["temp"], "0.8")
+        self.assertEqual(values["top-k"], "40")
+        self.assertEqual(values["top-p"], "0.95")
+        self.assertEqual(values["min-p"], "0.05")
+        self.assertEqual(values["repeat-penalty"], "1.0")
+
+
+class RenderIniTests(unittest.TestCase):
+    def test_render_ini_emits_range_comments_next_to_numeric_values(self):
+        entry = make_entry(
+            filename="plain-model.gguf",
+            architecture="qwen35moe",
+            context_length=131072,
+            block_count=99,
+            expert_count=128,
+            expert_used_count=8,
+        )
+
+        with patch("generate_models_preset.os.cpu_count", return_value=20):
+            text = render_ini(
+                entries=[entry],
+                models_dir=Path(r"C:\models-root"),
+                select_mode="all",
+                profile="smart",
+                alias_style="section",
+                backup_path=None,
+            )
+
+        self.assertRegex(
+            text,
+            re.compile(
+                r"; ctx-size range = 512\.\.131072; chosen = 43008\s+ctx-size = 43008",
+                re.MULTILINE,
+            ),
+        )
+        self.assertRegex(
+            text,
+            re.compile(
+                r"; n-gpu-layers range = 0\.\.99; chosen = 99\s+n-gpu-layers = 99",
+                re.MULTILINE,
+            ),
+        )
+        self.assertRegex(
+            text,
+            re.compile(
+                r"; n-cpu-moe range = 0\.\.99; chosen = 0\s+n-cpu-moe = 0",
+                re.MULTILINE,
+            ),
+        )
+        self.assertRegex(
+            text,
+            re.compile(
+                r"; threads range = 1\.\.20; chosen = 14\s+threads = 14",
+                re.MULTILINE,
+            ),
+        )
+        self.assertRegex(
+            text,
+            re.compile(
+                r"; parallel range = 1\.\.4; chosen = 4\s+parallel = 4",
+                re.MULTILINE,
+            ),
+        )
+        self.assertRegex(
+            text,
+            re.compile(
+                r"temp = 0\.7\s+top-k = 40\s+top-p = 0\.95\s+min-p = 0\.0\s+repeat-penalty = 1\.0",
+                re.MULTILINE,
+            ),
+        )
+        self.assertIn(
+            "; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived",
+            text,
+        )
+
+
+class ThreadHeuristicsTests(unittest.TestCase):
+    def test_threads_use_seventy_percent_of_logical_cpu_count(self):
+        entry = make_entry()
+        with patch("generate_models_preset.os.cpu_count", return_value=20):
+            values = build_preset_values(entry, "smart")
+
+        self.assertEqual(values["threads"], "14")
+
+
+class CliTests(unittest.TestCase):
+    def test_backup_is_disabled_by_default(self):
+        parser = build_parser()
+        args = parser.parse_args(["--models-dir", r"C:\models-root"])
+        self.assertFalse(args.backup)
+
+
+class AliasTests(unittest.TestCase):
+    def test_section_alias_matches_router_model_identifier_shape(self):
+        alias = make_alias(
+            publisher="ggml-org",
+            model_dir="Qwen3-8B-GGUF",
+            model_file="Qwen3-8B-Q4_K_M.gguf",
+            alias_style="section",
+            used_aliases=set(),
+        )
+        self.assertEqual(alias, "ggml-org/Qwen3-8B-GGUF:Q4_K_M")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 85 - 0
tests/test_start_scripts_ps1.py

@@ -0,0 +1,85 @@
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(r"E:\GitProjects\llama-cpp-server")
+ROUTER_SCRIPT = ROOT / "start-llama-router.ps1"
+ASR_SCRIPT = ROOT / "start-asr-server.ps1"
+QWEN_SCRIPT = ROOT / "start-qwen3.6-35b-a3b.ps1"
+
+
+class Ps1MigrationTests(unittest.TestCase):
+    def test_ps1_scripts_exist(self):
+        self.assertTrue(ROUTER_SCRIPT.exists(), ROUTER_SCRIPT)
+        self.assertTrue(ASR_SCRIPT.exists(), ASR_SCRIPT)
+        self.assertTrue(QWEN_SCRIPT.exists(), QWEN_SCRIPT)
+
+    def test_legacy_bat_scripts_are_removed(self):
+        self.assertFalse((ROOT / "start-llama-router.bat").exists())
+        self.assertFalse((ROOT / "start-asr-server.bat").exists())
+        self.assertFalse((ROOT / "start-qwen3.6-35b-a3b.bat").exists())
+
+
+class RouterScriptTests(unittest.TestCase):
+    def test_router_script_uses_utf8_and_optional_gated_server_flags(self):
+        text = ROUTER_SCRIPT.read_text(encoding="utf-8")
+        self.assertIn("[Console]::OutputEncoding", text)
+        self.assertIn("$env:PYTHONIOENCODING = 'utf-8'", text)
+        self.assertIn("--models-preset", text)
+        self.assertIn("--models-autoload", text)
+        self.assertIn("--models-max", text)
+        self.assertIn("--sleep-idle-seconds", text)
+        self.assertIn("--cache-idle-slots", text)
+        self.assertIn("--api-key", text)
+        self.assertIn("--timeout", text)
+        self.assertIn("--cache-ram", text)
+        self.assertIn("--threads-http", text)
+        self.assertIn("--metrics", text)
+        self.assertIn("--ssl-cert-file", text)
+        self.assertIn("--ssl-key-file", text)
+        self.assertIn("--ui", text)
+        self.assertIn("--no-ui", text)
+        self.assertIn("if ($ApiKey)", text)
+        self.assertIn("if ($EnableMetrics)", text)
+
+    def test_router_script_can_regenerate_models_preset(self):
+        text = ROUTER_SCRIPT.read_text(encoding="utf-8")
+        self.assertIn("generate_models_preset.py", text)
+        self.assertIn("--alias-style", text)
+        self.assertIn("--profile", text)
+        self.assertIn("--select", text)
+
+
+class SingleModelScriptTests(unittest.TestCase):
+    def test_asr_script_uses_mmproj_and_ps1_argument_array(self):
+        text = ASR_SCRIPT.read_text(encoding="utf-8")
+        self.assertIn("$Args = @(", text)
+        self.assertIn("-m", text)
+        self.assertIn("--mmproj", text)
+        self.assertIn("--parallel", text)
+        self.assertIn("--mmap", text)
+
+    def test_qwen_script_uses_recommended_threads_and_moe_runtime_flags(self):
+        text = QWEN_SCRIPT.read_text(encoding="utf-8")
+        self.assertIn("[Environment]::ProcessorCount", text)
+        self.assertIn("* 0.7", text)
+        self.assertIn("-b", text)
+        self.assertIn("2048", text)
+        self.assertIn("-ub", text)
+        self.assertIn("512", text)
+        self.assertIn("--parallel", text)
+        self.assertIn("4", text)
+        self.assertIn("-ncmoe", text)
+        self.assertIn("--mmap", text)
+        self.assertIn("--reasoning", text)
+        self.assertIn("--reasoning-budget", text)
+        self.assertIn("--spec-type", text)
+        self.assertIn("--temp", text)
+        self.assertIn("--top-k", text)
+        self.assertIn("--top-p", text)
+        self.assertIn("--min-p", text)
+        self.assertIn("--repeat-penalty", text)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 295 - 0
tests/transcribe.py

@@ -0,0 +1,295 @@
+#!/usr/bin/env python3
+"""
+Qwen3-ASR-1.7B 智能分段转录 v3
+- Silero VAD ONNX 在自然停顿处切分
+- 合并小段为 30~60s 大块,边界完整
+- 多线程并行转录
+- 输出 TXT / SRT / JSON
+"""
+import argparse
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+sys.stdout = __import__("io").TextIOWrapper(
+    sys.stdout.buffer, encoding="utf-8", errors="replace"
+)
+
+import numpy as np
+import soundfile as sf
+import torch
+import silero_vad
+
+import requests
+
+MODEL = "ggml-org/Qwen3-ASR-1.7B-GGUF:Q8_0"
+PORT = os.environ.get("QWEN3_ASR_PORT", "18003")
+API_URL = f"http://localhost:{PORT}/v1/audio/transcriptions"
+REPORT_DIR = Path(os.path.dirname(__file__)) / "reports"
+
+_http = requests.Session()
+_http.headers.update({"Accept": "application/json"})
+
+
+@dataclass
+class Segment:
+    start: float
+    end: float
+
+@dataclass
+class Chunk:
+    segments: list[Segment]
+    text: str = ""
+
+    @property
+    def start(self) -> float:
+        return self.segments[0].start
+
+    @property
+    def end(self) -> float:
+        return self.segments[-1].end
+
+    @property
+    def audio_duration(self) -> float:
+        return sum(s.end - s.start for s in self.segments)
+
+    @property
+    def wall_duration(self) -> float:
+        return self.end - self.start
+
+
+def ffmpeg_run(args: list[str], check=True):
+    subprocess.run(
+        ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args,
+        capture_output=True, check=check
+    )
+
+
+def extract_wav(src: str, dst: str, start: float, duration: float):
+    ffmpeg_run([
+        "-i", src, "-ss", str(start), "-t", str(duration),
+        "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", dst
+    ])
+
+
+def find_speech_segments(audio_path: str) -> list[Segment]:
+    """用 Silero VAD 检出所有语音片段"""
+    data, sr = sf.read(audio_path)
+    audio_t = torch.from_numpy(data).float()
+    model = silero_vad.load_silero_vad(onnx=True)
+    raw = silero_vad.get_speech_timestamps(
+        audio_t, model,
+        threshold=0.5,
+        min_speech_duration_ms=300,
+        min_silence_duration_ms=400,
+        return_seconds=True
+    )
+    return [Segment(s["start"], s["end"]) for s in raw]
+
+
+def group_chunks(segments: list[Segment],
+                 target: float = 45,
+                 min_size: float = 20,
+                 max_size: float = 75) -> list[Chunk]:
+    """将语音片段合并为合适的转录块"""
+    chunks = []
+    cur: list[Segment] = []
+    cur_audio = 0.0
+
+    for seg in segments:
+        dur = seg.end - seg.start
+        if cur_audio + dur > max_size and cur_audio >= min_size:
+            chunks.append(Chunk(cur))
+            cur = []
+            cur_audio = 0.0
+        cur.append(seg)
+        cur_audio += dur
+
+    if cur:
+        if cur_audio < min_size and chunks:
+            chunks[-1].segments.extend(cur)
+        else:
+            chunks.append(Chunk(cur))
+
+    return chunks
+
+
+def transcribe_chunk(audio_path: str, chunk: Chunk, tmp_dir: str) -> str:
+    wav = os.path.join(tmp_dir, f"c{chunk.start:07.1f}.wav")
+    extract_wav(audio_path, wav, chunk.start, chunk.wall_duration)
+    with open(wav, "rb") as f:
+        r = _http.post(API_URL, data={"model": MODEL}, files={"file": f}, timeout=300)
+    text = r.text.strip()
+    try:
+        parsed = r.json()
+        text = parsed.get("text", text)
+    except Exception:
+        pass
+    return text
+
+
+def strip_prefix(text: str) -> str:
+    idx = text.find("<asr_text>")
+    return text[idx + 10:].strip() if idx >= 0 else text.strip()
+
+
+def to_srt(chunks: list[Chunk]) -> str:
+    lines = []
+    for i, c in enumerate(chunks, 1):
+        text = strip_prefix(c.text)
+        start_s = format_ts(c.start)
+        end_s = format_ts(c.end)
+        lines.append(f"{i}")
+        lines.append(f"{start_s} --> {end_s}")
+        lines.append(text)
+        lines.append("")
+    return "\n".join(lines)
+
+
+def format_ts(seconds: float) -> str:
+    h = int(seconds // 3600)
+    m = int((seconds % 3600) // 60)
+    s = seconds % 60
+    return f"{h:02d}:{m:02d}:{s:06.3f}".replace(".", ",")
+
+
+def transcribe_video(video_path: str,
+                     target: int = 45,
+                     workers: int = 4,
+                     output: str = "txt"):
+    if not os.path.isfile(video_path):
+        print(f"[ERROR] File not found: {video_path}")
+        return
+
+    t_start = time.time()
+    video_name = Path(video_path).stem
+    tmp = os.path.join(os.environ.get("TEMP", "C:\\Temp"), "qwen3_vad")
+    os.makedirs(tmp, exist_ok=True)
+
+    # ── 1. 提取完整 WAV ──
+    full_wav = os.path.join(tmp, "full.wav")
+    print("Extracting audio...", end=" ", flush=True)
+    t0 = time.time()
+    extract_wav(video_path, full_wav, 0, 999999)
+    print(f"{time.time()-t0:.2f}s")
+
+    # ── 2. VAD 检测 ──
+    print("VAD detecting speech...", end=" ", flush=True)
+    t0 = time.time()
+    segs = find_speech_segments(full_wav)
+    print(f"{time.time()-t0:.2f}s ({len(segs)} segments)")
+
+    # ── 3. 分组 ──
+    chunks = group_chunks(segs, target=target)
+    print(f"Grouped into {len(chunks)} chunks")
+
+    # ── 4. 并行转写 ──
+    done = [0]
+    lock = threading.Lock()
+    errs = []
+
+    def process(c: Chunk):
+        try:
+            text = transcribe_chunk(full_wav, c, tmp)
+            with lock:
+                c.text = text
+        except Exception as e:
+            with lock:
+                errs.append((c.start, str(e)))
+        with lock:
+            done[0] += 1
+            sys.stdout.write(f"\r  [{done[0]}/{len(chunks)}]")
+            sys.stdout.flush()
+
+    t1 = time.time()
+    with ThreadPoolExecutor(max_workers=workers) as pool:
+        for f in as_completed([pool.submit(process, c) for c in chunks]):
+            f.result()
+
+    trans_time = time.time() - t1
+    trans_audio = sum(c.audio_duration for c in chunks)
+
+    # ── 5. 合并结果 ──
+    full_text = "".join(strip_prefix(c.text) for c in chunks if c.text)
+
+    # ── 6. 报告 ──
+    total = time.time() - t_start
+    rtf = round(total / trans_audio, 4)
+    speed = round(trans_audio / trans_time, 2)
+
+    print(f"\n\nAudio: {trans_audio:.0f}s ({trans_audio/60:.1f} min)")
+    print(f"Transcribe: {trans_time:.1f}s  Total: {total:.1f}s")
+    print(f"RTF: {rtf}  Speed: {speed}x")
+    print(f"\n--- Result ({len(full_text)} chars) ---")
+    print(full_text[:600] + "..." if len(full_text) > 600 else full_text)
+
+    # ── 7. 输出文件 ──
+    os.makedirs(REPORT_DIR, exist_ok=True)
+    now = datetime.now().strftime("%Y%m%d-%H%M%S")
+    base = os.path.join(REPORT_DIR, f"{video_name}-{now}")
+
+    if output in ("txt", "all"):
+        p = f"{base}.txt"
+        with open(p, "w", encoding="utf-8") as f:
+            f.write(full_text)
+        print(f"TXT:  {p}")
+
+    if output in ("srt", "all"):
+        p = f"{base}.srt"
+        with open(p, "w", encoding="utf-8") as f:
+            f.write(to_srt(chunks))
+        print(f"SRT:  {p}")
+
+    if output in ("json", "all"):
+        data = {
+            "model": MODEL, "source": video_path,
+            "duration": trans_audio,
+            "rtf": rtf, "speed": speed,
+            "chunks": [
+                {"start": c.start, "end": c.end,
+                 "text": strip_prefix(c.text)}
+                for c in chunks if c.text
+            ],
+            "text": full_text
+        }
+        p = f"{base}.json"
+        with open(p, "w", encoding="utf-8") as f:
+            json.dump(data, f, ensure_ascii=False, indent=2)
+        print(f"JSON: {p}")
+
+    # 清理
+    try:
+        shutil.rmtree(tmp)
+    except Exception:
+        pass
+
+    return full_text
+
+
+def main():
+    p = argparse.ArgumentParser(description="Qwen3-ASR 智能分段转录 v3")
+    p.add_argument("--video", default=None)
+    p.add_argument("--target", type=int, default=45,
+                   help="目标每段时长(秒), 默认45")
+    p.add_argument("--workers", type=int, default=4)
+    p.add_argument("--output", choices=["txt", "srt", "json", "all"],
+                   default="txt", help="输出格式")
+    args = p.parse_args()
+
+    video = args.video or (r"G:\Download\BaiduNetdiskDownload\个人成长--合集\打造AI时代的终身学习力:重构被异化的学习更新中\6-模型预测:模型预测未见-1080P 高清-AVC.mp4"
+    )
+    transcribe_video(video, args.target, args.workers, args.output)
+
+
+if __name__ == "__main__":
+    main()