From 80aa501c09ea3224430bc7e8536fc0bafab2eed9 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 28 May 2026 21:58:27 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix=20voice=20auto-install=20chain=20?= =?UTF-8?q?=E2=80=94=20use=20canonical=20lmstudio-community=20GGUF=20ids?= =?UTF-8?q?=20and=20skip=20non-lmstudio=20voice=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous chain referenced model ids that `lms get` can't fetch: qwen/qwen3.5-9b was never released (Qwen line is 2.5 → 3 → 3.6); meta-llama/Llama-3.1-8B-Instruct is a gated HF repo; qwen/qwen3-4b-2507 is the MLX-only slug. Each attempt failed in ~100 ms with empty stderr, which the regex-filtered warning rendered as "unavailable ()". Replace with the un-gated lmstudio-community/...-GGUF ids that already appear in the curated localLlmCatalog so the "recommended installs" UI and the voice auto-installer agree on the same model set. Also gate ensureToolCapableModel / preloadModel on the voice provider so Ollama users don't see the install chain run for a CLI they're not using, and rewrite the per-attempt warning to surface the last non-empty line of stderr (or stdout) instead of the trailing newline. --- server/services/voice/bootstrap.js | 37 +++++++++++++++++++----------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/server/services/voice/bootstrap.js b/server/services/voice/bootstrap.js index fee851e00c..e643e6fbf8 100644 --- a/server/services/voice/bootstrap.js +++ b/server/services/voice/bootstrap.js @@ -211,25 +211,33 @@ export const stopWhisper = async () => { // Default tool-capable model to auto-install via `lms get` when the user has // voice.enabled + tools.enabled + model='auto' but LM Studio has no model that // speaks OpenAI structured tool_calls. We try a small list in order — first -// one resolved successfully wins. Picks favor: small (≤7B), explicitly +// one resolved successfully wins. Picks favor: small (≤8B), explicitly // non-reasoning ("instruct"/"2507" non-thinking variants), and currently -// available in the LM Studio Hub catalog (the catalog evolves, so we try -// newer slugs first and keep older ones as fallbacks). Override the entire -// chain with PORTOS_VOICE_DEFAULT_TOOL_MODEL (single id). +// available in the LM Studio Hub catalog under the un-gated +// `lmstudio-community/...-GGUF` form (`lms get` needs an actual fetchable HF +// repo; gated repos like `meta-llama/*` and MLX-only ids fail silently). +// Ids mirror the curated catalog in `server/lib/localLlmCatalog.js` so the +// "recommended installs" UI stays consistent with what voice auto-installs. +// Override the entire chain with PORTOS_VOICE_DEFAULT_TOOL_MODEL (single id). const DEFAULT_TOOL_MODEL_CHAIN = () => { const override = process.env.PORTOS_VOICE_DEFAULT_TOOL_MODEL; if (override) return [override]; return [ - 'qwen/qwen3-4b-2507', // 4B, non-thinking, ~2.3 GB MLX, fast TTFT - 'qwen/qwen3.5-9b', // 9B, current Qwen3.5 line - 'lmstudio-community/Qwen2.5-7B-Instruct-GGUF', // older, kept for older catalogs - 'meta-llama/Llama-3.1-8B-Instruct', // Llama fallback + 'lmstudio-community/Qwen3-4B-Instruct-2507-GGUF', // 4B, ~2.6 GB, current non-thinking Qwen3 + 'lmstudio-community/Llama-3.2-3B-Instruct-GGUF', // 3B, ~2 GB, smaller fallback + 'lmstudio-community/Qwen2.5-7B-Instruct-GGUF', // 7B, ~4.7 GB, classic workhorse + 'lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF', // 8B, ~4.7 GB, Llama fallback ]; }; const LMS_BASE = () => (process.env.LM_STUDIO_URL || 'http://localhost:1234') .replace(/\/+$/, '').replace(/\/v1$/, ''); +// The auto-install + preload paths only know how to talk to LM Studio +// (`lms get`, `lms load`, `/v1/models`). Default to `lmstudio` for installs +// that pre-date `voice.llm.provider`, where the field is absent. +const isLmStudioVoiceProvider = (cfg) => (cfg?.llm?.provider || 'lmstudio') === 'lmstudio'; + const listLmStudioModels = async () => { const res = await fetch(`${LMS_BASE()}/v1/models`, { signal: AbortSignal.timeout(5000) }).catch(() => null); if (!res?.ok) return []; @@ -260,6 +268,7 @@ export const ensureToolCapableModel = async (cfg) => { // if incompatible. if (!cfg?.llm?.tools?.enabled) return { skipped: 'tools-disabled' }; if (cfg?.llm?.model && cfg.llm.model !== 'auto') return { skipped: 'explicit-model' }; + if (!isLmStudioVoiceProvider(cfg)) return { skipped: 'non-lmstudio-provider', provider: cfg?.llm?.provider }; const installed = await listLmStudioModels(); // Tool-capable AND non-reasoning AND under the size cap. The size cap is @@ -302,11 +311,12 @@ export const ensureToolCapableModel = async (cfg) => { console.log(`🎙️ voice: fast tool-capable model ready — ${fastNew}`); return { installed: fastNew }; } - if (stderr && /not exist|not found|permission|failed/i.test(stderr)) { - console.warn(`🎙️ voice: ${target} unavailable (${stderr.split('\n').pop().slice(0, 160)}) — trying next`); - continue; - } - console.warn(`🎙️ voice: lms get ${target} returned but no new fast model detected — trying next`); + // Pick the last non-empty line from stderr (LM Studio CLI trails newlines) + // so the warning is actionable instead of an empty `()`. Combine with + // stdout when stderr is empty — `lms` sometimes routes errors to stdout. + const lastMeaningfulLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).pop() || ''; + const reason = lastMeaningfulLine(stderr) || lastMeaningfulLine(stdout) || 'unknown'; + console.warn(`🎙️ voice: ${target} unavailable (${reason.slice(0, 160)}) — trying next`); } console.warn(`🎙️ voice: exhausted install chain ${chain.join(', ')} — set voice.llm.model explicitly in Settings`); return { failed: chain }; @@ -338,6 +348,7 @@ const listLoadedModelKeys = async () => { export const preloadModel = async (cfg) => { if (!cfg?.enabled) return { skipped: 'voice-disabled' }; if (cfg?.llm?.model && cfg.llm.model !== 'auto') return { skipped: 'explicit-model' }; + if (!isLmStudioVoiceProvider(cfg)) return { skipped: 'non-lmstudio-provider', provider: cfg?.llm?.provider }; const lms = await which('lms'); if (!lms) return { skipped: 'no-lms-cli' }; const installed = await listLmStudioModels(); From 8fb59090eeb102e5daa00b2c4ccc1168babedbde Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 28 May 2026 22:01:43 -0700 Subject: [PATCH 2/2] address review (codex): resolve effective voice provider before skipping LM Studio provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveLlmEndpoint in llm.js falls back to LM Studio whenever voice.llm.provider points to a deleted, CLI-type, or endpoint-less provider — meaning the chat path still uses LM Studio. The previous gate short-circuited on the raw provider id string and would skip provisioning in those fallback cases, leaving voice without a tool-capable model. Mirror the resolution logic: only skip when the configured provider id actually resolves to a usable non-lmstudio api-type backend. --- server/services/voice/bootstrap.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/server/services/voice/bootstrap.js b/server/services/voice/bootstrap.js index e643e6fbf8..0b0a958970 100644 --- a/server/services/voice/bootstrap.js +++ b/server/services/voice/bootstrap.js @@ -10,6 +10,7 @@ import { PATHS } from '../../lib/fileUtils.js'; import { execPm2, getAppStatus } from '../pm2.js'; import { expandPath, piperVoiceTildePath, voiceHome, IS_WIN, PIPER_BIN_NAME } from './config.js'; import { isToolCapable, isReasoningModel } from './llm.js'; +import { getProviderById } from '../providers.js'; import { fetchWithTimeout } from '../../lib/fetchWithTimeout.js'; export const pexec = promisify(execFile); @@ -234,9 +235,17 @@ const LMS_BASE = () => (process.env.LM_STUDIO_URL || 'http://localhost:1234') .replace(/\/+$/, '').replace(/\/v1$/, ''); // The auto-install + preload paths only know how to talk to LM Studio -// (`lms get`, `lms load`, `/v1/models`). Default to `lmstudio` for installs -// that pre-date `voice.llm.provider`, where the field is absent. -const isLmStudioVoiceProvider = (cfg) => (cfg?.llm?.provider || 'lmstudio') === 'lmstudio'; +// (`lms get`, `lms load`, `/v1/models`). Mirror `resolveLlmEndpoint` in +// `llm.js`: voice falls back to LM Studio whenever the configured provider +// is missing, not api-type, or has no endpoint — so we still want to +// provision in those cases. Only skip when the configured provider really +// resolves to a usable non-lmstudio backend (e.g. a working Ollama). +const isEffectiveLmStudioVoiceProvider = async (cfg) => { + const providerId = cfg?.llm?.provider || 'lmstudio'; + if (providerId === 'lmstudio') return true; + const provider = await getProviderById(providerId).catch(() => null); + return !(provider && provider.type === 'api' && provider.endpoint); +}; const listLmStudioModels = async () => { const res = await fetch(`${LMS_BASE()}/v1/models`, { signal: AbortSignal.timeout(5000) }).catch(() => null); @@ -268,7 +277,7 @@ export const ensureToolCapableModel = async (cfg) => { // if incompatible. if (!cfg?.llm?.tools?.enabled) return { skipped: 'tools-disabled' }; if (cfg?.llm?.model && cfg.llm.model !== 'auto') return { skipped: 'explicit-model' }; - if (!isLmStudioVoiceProvider(cfg)) return { skipped: 'non-lmstudio-provider', provider: cfg?.llm?.provider }; + if (!(await isEffectiveLmStudioVoiceProvider(cfg))) return { skipped: 'non-lmstudio-provider', provider: cfg?.llm?.provider }; const installed = await listLmStudioModels(); // Tool-capable AND non-reasoning AND under the size cap. The size cap is @@ -348,7 +357,7 @@ const listLoadedModelKeys = async () => { export const preloadModel = async (cfg) => { if (!cfg?.enabled) return { skipped: 'voice-disabled' }; if (cfg?.llm?.model && cfg.llm.model !== 'auto') return { skipped: 'explicit-model' }; - if (!isLmStudioVoiceProvider(cfg)) return { skipped: 'non-lmstudio-provider', provider: cfg?.llm?.provider }; + if (!(await isEffectiveLmStudioVoiceProvider(cfg))) return { skipped: 'non-lmstudio-provider', provider: cfg?.llm?.provider }; const lms = await which('lms'); if (!lms) return { skipped: 'no-lms-cli' }; const installed = await listLmStudioModels();