feat(desktop): list config-defined command TTS/STT providers in settings

The Settings > Voice provider dropdowns (tts.provider / stt.provider) only offer
the built-in providers plus whatever value is currently set. Custom `type: command`
providers declared in config.yaml aren't selectable — and once you switch away from
one it drops off the list, so you can only return to it by hand-editing config.

enumOptionsFor now merges in the names of any `type: command` entries under the
tts/stt config sections, so local command-backed engines appear alongside the
built-ins and can be switched freely from the UI.

Enumeration mirrors the runtime's own resolution so the dropdown can only offer a
name the runtime would actually honour: the canonical `<section>.providers.<name>`
location plus the back-compat top-level `<section>.<name>` block, the optional
`type:` discriminator, and the built-in-name guard. The guard compares against the
runtime's built-in sets rather than the ENUM_OPTIONS display list, which is not a
substitute — it already omits `deepinfra` (TTS) and `deepinfra`/`local_command`
(STT), so a `providers.deepinfra` command block would otherwise be offered as
selectable while the runtime dispatches to the native backend instead.

- helpers.ts: add commandProviderNames() + the built-in guard; merge for
  tts.provider + stt.provider
- helpers.test.ts: cover both sections, incl. that non-command config blocks
  aren't offered and that built-ins absent from the display list are never
  offered as command providers
This commit is contained in:
laurinaitis 2026-07-11 13:15:16 +09:00 committed by Teknium
parent a729a5d386
commit e58534f9d7
2 changed files with 201 additions and 2 deletions

View file

@ -205,6 +205,99 @@ describe('settings helpers', () => {
expect(opts).toContain('my-custom-command-tts')
expect(opts).toContain('xai')
})
it('surfaces user-defined command-type TTS providers (canonical providers nesting + legacy)', () => {
const withCustom: HermesConfigRecord = {
tts: {
provider: 'neutts',
// canonical location the runtime resolves first: tts.providers.<name>
providers: {
higgs8: { type: 'command', command: 'curl …' },
indextts2: { type: 'command', command: 'curl …' },
// `type:` is optional at runtime — a bare command block still qualifies
typeless: { command: 'curl …' },
// misconfigured: type:command but no command → NOT a runtime provider
noop: { type: 'command' }
},
// back-compat: a top-level tts.<name> command block still resolves at runtime
mylegacy: { type: 'command', command: 'curl …' },
// a non-command block (built-in config) must NOT be offered as a provider
edge: { voice: 'en-US-JennyNeural' }
}
}
const opts = enumOptionsFor('tts.provider', 'neutts', withCustom)
expect(opts).toContain('higgs8') // canonical providers.<name>
expect(opts).toContain('indextts2') // canonical providers.<name>
expect(opts).toContain('typeless') // command block with no type: still surfaced
expect(opts).toContain('mylegacy') // legacy top-level tts.<name>
expect(opts).toContain('elevenlabs') // built-ins preserved
expect(opts).not.toContain('noop') // type:command with no command is excluded
// 'edge' appears once (the built-in), not duplicated by the config block
expect(opts!.filter(o => o === 'edge')).toHaveLength(1)
// the 'providers' container itself is never offered as a provider name
expect(opts).not.toContain('providers')
})
it('surfaces command-type STT providers too (canonical providers nesting)', () => {
const withCustom: HermesConfigRecord = {
stt: {
provider: 'local',
providers: { myasr: { type: 'command', command: 'curl …' } }
}
}
const opts = enumOptionsFor('stt.provider', 'local', withCustom)
expect(opts).toContain('myasr')
expect(opts).toContain('local')
expect(opts).not.toContain('providers')
})
// The runtime rejects a built-in name as a command provider before any config
// lookup, so such a block must never be offered — including the names the
// display list omits (`deepinfra` for TTS; `deepinfra`/`local_command` for
// STT), where filtering on ENUM_OPTIONS instead of the runtime's built-in set
// would wrongly offer a provider that can never dispatch.
it('never offers a built-in name as a command provider, even one absent from the dropdown list', () => {
const shadowing: HermesConfigRecord = {
tts: {
provider: 'edge',
providers: {
// built-in and absent from ENUM_OPTIONS['tts.provider']
deepinfra: { type: 'command', command: 'curl …' },
// built-in guard is case-insensitive at runtime (provider.lower())
EDGE: { type: 'command', command: 'curl …' },
// a genuine custom provider alongside them still surfaces
higgs8: { type: 'command', command: 'curl …' }
}
}
}
const opts = enumOptionsFor('tts.provider', 'edge', shadowing)
expect(opts).not.toContain('deepinfra')
expect(opts).not.toContain('EDGE')
expect(opts).toContain('higgs8')
expect(opts!.filter(o => o === 'edge')).toHaveLength(1)
})
it('never offers a built-in STT name absent from the dropdown list as a command provider', () => {
const shadowing: HermesConfigRecord = {
stt: {
provider: 'local',
providers: {
// both are built-in STT names omitted from ENUM_OPTIONS['stt.provider']
local_command: { type: 'command', command: 'curl …' },
deepinfra: { type: 'command', command: 'curl …' },
myasr: { type: 'command', command: 'curl …' }
}
}
}
const opts = enumOptionsFor('stt.provider', 'local', shadowing)
expect(opts).not.toContain('local_command')
expect(opts).not.toContain('deepinfra')
expect(opts).toContain('myasr')
})
})
describe('sectionFieldEntries', () => {

View file

@ -1,4 +1,4 @@
import { asText } from '@/lib/text'
import { asText, normalize } from '@/lib/text'
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants'
@ -166,13 +166,119 @@ function personalityOptions(config: HermesConfigRecord): string[] {
return [...new Set(['', ...BUILTIN_PERSONALITIES, ...customNames])]
}
// Built-in provider names, mirroring `tts_tool.py:BUILTIN_TTS_PROVIDERS` and
// `transcription_tools.py:BUILTIN_STT_PROVIDERS`. The runtime rejects a built-in
// name as a command provider before any config lookup
// (`_resolve_command_provider_config`: `key = provider.lower().strip()`, then
// `if key in BUILTIN_*_PROVIDERS: return None`), so a ``providers.edge`` block
// declaring ``type: command`` still dispatches to native Edge.
//
// These are deliberately NOT derived from `ENUM_OPTIONS`, which is a *display*
// list and already drifts from the runtime sets: it omits `deepinfra` (TTS) and
// `deepinfra`/`local_command` (STT). Filtering on the display list would offer
// those names as command providers that the runtime would never honour.
const BUILTIN_TTS_PROVIDERS = new Set([
'edge',
'elevenlabs',
'openai',
'minimax',
'xai',
'mistral',
'gemini',
'neutts',
'kittentts',
'piper',
'deepinfra'
])
const BUILTIN_STT_PROVIDERS = new Set([
'local',
'local_command',
'groq',
'openai',
'mistral',
'xai',
'elevenlabs',
'deepinfra'
])
// A user-declared command provider, mirroring the runtime discriminator
// (`tts_tool.py:_is_command_provider_config` / `transcription_tools.py`): `type`
// is OPTIONAL and case/space-insensitive (absent or normalizing to "command"),
// and `command` MUST be a non-empty string. So a canonical block written as just
// ``{ command: "curl …" }`` with no ``type:`` — a fully valid runtime provider
// under ``providers.*`` — qualifies too, while built-in blocks (which carry
// ``voice``/``model`` and no ``command``) and the ``providers`` container itself
// (no ``command``) are skipped.
function isCommandProvider(value: unknown): boolean {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const record = value as Record<string, unknown>
const type = normalize(record.type)
if (type !== '' && type !== 'command') {
return false
}
return typeof record.command === 'string' && record.command.trim() !== ''
}
// Names of user-defined command providers, so the settings dropdown can offer
// them alongside the built-ins instead of only whichever one is currently active
// (otherwise, once you switch away from a custom provider it drops off the list
// and can only be reselected by hand-editing config.yaml).
//
// Mirrors the runtime's dual resolution (`tts_tool.py:_get_named_provider_config`,
// `transcription_tools.py`): the CANONICAL location is nested —
// ``tts.providers.<name>`` / ``stt.providers.<name>`` — with a back-compat
// fallback to a top-level ``tts.<name>`` / ``stt.<name>`` block. We enumerate
// both (deduped), keeping only sections that satisfy isCommandProvider and whose
// name the runtime would actually resolve as a command provider — built-ins are
// excluded case-insensitively, matching the runtime's `provider.lower().strip()`
// guard, so a ``providers.EDGE`` command block is not offered.
function commandProviderNames(config: HermesConfigRecord, section: 'tts' | 'stt'): string[] {
const builtins = section === 'tts' ? BUILTIN_TTS_PROVIDERS : BUILTIN_STT_PROVIDERS
const names = new Set<string>()
for (const path of [`${section}.providers`, section]) {
const block = getNested(config, path)
if (!block || typeof block !== 'object' || Array.isArray(block)) {
continue
}
for (const [name, value] of Object.entries(block as Record<string, unknown>)) {
if (isCommandProvider(value) && !builtins.has(normalize(name))) {
names.add(name)
}
}
}
return [...names]
}
export function enumOptionsFor(
key: string,
value: unknown,
config: HermesConfigRecord,
dynamicOptions?: string[]
): string[] | undefined {
const opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key])
let opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key])
// Merge in user-defined command-type providers so custom local TTS/STT
// backends declared in config.yaml are selectable, not just the built-ins.
// The `includes` guard keeps the list duplicate-free should the display list
// ever carry a name we also enumerate.
if (!dynamicOptions && opts && (key === 'tts.provider' || key === 'stt.provider')) {
const section = key.slice(0, 3) as 'tts' | 'stt'
const custom = commandProviderNames(config, section).filter(name => !opts!.includes(name))
if (custom.length > 0) {
opts = [...opts, ...custom]
}
}
if (!opts) {
return undefined