diff --git a/server/services/voice/bootstrap.js b/server/services/voice/bootstrap.js index fee851e00c..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); @@ -211,25 +212,41 @@ 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`). 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); if (!res?.ok) return []; @@ -260,6 +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 (!(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 @@ -302,11 +320,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 +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 (!(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();