Skip to content

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

Closed
laurinaitis wants to merge 1 commit into
NousResearch:mainfrom
laurinaitis:fix/desktop-tts-provider-dropdown
Closed

feat(desktop): list config-defined command TTS/STT providers in settings#62459
laurinaitis wants to merge 1 commit into
NousResearch:mainfrom
laurinaitis:fix/desktop-tts-provider-dropdown

Conversation

@laurinaitis

@laurinaitis laurinaitis commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Problem

Settings → Voice → Text-To-Speech Provider (and the STT equivalent) list the built-in providers plus whatever value is currently set. Custom type: command providers defined in config.yaml aren't offered, so:

  • they can't be selected from the UI at all, and
  • once you switch away from a custom provider it disappears from the list, and the only way back is hand-editing config.yaml.

This bites anyone running a local command-backed TTS/STT engine, e.g. a self-hosted OpenAI-compatible voice server wired up as a command provider.

Fix

enumOptionsFor (apps/desktop/src/app/settings/helpers.ts) merges in the names of user-defined command providers, so local command-backed engines appear alongside the built-ins and can be switched freely.

Enumeration mirrors the runtime's own resolution, so the dropdown can only offer a name the runtime would honour:

  • Location. commandProviderNames reads the canonical tts.providers.<name> / stt.providers.<name>, plus the back-compat top-level tts.<name> / stt.<name> block, deduped - matching _get_named_provider_config (tools/tts_tool.py) and _get_named_stt_provider_config (tools/transcription_tools.py).
  • Shape. The command check mirrors _is_command_provider_config: type is optional and case/space-insensitive (absent or normalising to command), with a required non-empty command. Non-command blocks (a built-in's own settings sub-object) and the providers container are not offered.
  • Built-in names. Excluded case-insensitively against the runtime's BUILTIN_TTS_PROVIDERS / BUILTIN_STT_PROVIDERS, matching the provider.lower().strip() guard in _resolve_command_provider_config (tools/tts_tool.py:472). ENUM_OPTIONS is a display list and isn't a substitute here: it omits deepinfra (TTS) and deepinfra / local_command (STT), so filtering on it would offer a providers.deepinfra command block as selectable while the runtime dispatches to the native backend.

Existing behaviour is unchanged: built-ins, then the current value appended if unknown. Nothing about provider dispatch changes.

Scope is providers declared in config.yaml. Plugin-registered provider names live in the runtime registry (hermes_cli/plugins.py) and would need a backend API to surface, so they're out of this diff.

Tests

helpers.test.ts covers, for both TTS and STT: custom command providers surfacing from the canonical providers nesting and the legacy top-level shape; a bare { command: … } block with no type:; a misconfigured { type: command } with no command excluded; a non-command block coinciding with a built-in name not duplicated; the providers container not offered; and built-in names never offered as command providers, including the ones the display list omits. The last two cases fail on the previous filter.

Desktop settings suite: 70 passing. eslint clean on the changed lines. Rebased onto main; tsc --noEmit shows four pre-existing errors in assistant-ui files, unchanged from a stock main checkout and untouched here.

Related

#50081 and #50612 added command-type providers; this makes them selectable from the settings UI. Adjacent settings/config UX: #50698.

Prior art on these dropdowns, which I should have cited when filing:

@alt-glitch alt-glitch added type/feature New feature or request comp/desktop Electron desktop app (apps/desktop/*) tool/tts Text-to-speech and transcription P3 Low — cosmetic, nice to have labels Jul 11, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing a real desktop settings gap. Current main still limits enumOptionsFor to built-ins plus the selected value (apps/desktop/src/app/settings/helpers.ts:141-149).

Problems

  • apps/desktop/src/app/settings/helpers.ts:143 scans immediate tts/stt children. Current dispatch resolves custom providers from tts.providers.<name> (tools/tts_tool.py:422-429) and stt.providers.<name> (tools/transcription_tools.py:288-301), so canonical command providers are not returned by this implementation.
  • The new fixtures use direct tts.<name> / stt.<name> blocks (helpers.test.ts:180-185, :199), so they do not cover the documented and primary runtime layout.
  • The change also replaces the canonical @/lib/text re-export with duplicate helpers (helpers.ts:5-9), reversing the consolidation in 7e6d60aadccc4d513aaf6e1869952c68b5135d71.

Suggested changes

  • Enumerate section.providers, test canonical TTS and STT fixtures, and retain the existing text-helper re-export.

Automated hermes-sweeper review.

// 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).
function commandProviderNames(config: HermesConfigRecord, section: 'tts' | 'stt'): string[] {
const block = getNested(config, section)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Command providers are canonically nested under tts.providers.<name> / stt.providers.<name> (tools/tts_tool.py:422-429, tools/transcription_tools.py:288-301). Scanning the section itself sees providers as one object with no type, so documented providers are never added. Please enumerate the nested providers record and update the tests to use that shape.

// Canonical implementations live in @/lib/text; re-exported here so the many
// settings/capabilities call sites keep their import path.
export { asText, includesQuery, prettyName } from '@/lib/text'
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please retain the existing @/lib/text import and re-export. Current main deliberately centralizes these helpers there (7e6d60aadccc4d513aaf6e1869952c68b5135d71); this duplicate definition is unrelated to the provider-picker change.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 11, 2026
@laurinaitis
laurinaitis force-pushed the fix/desktop-tts-provider-dropdown branch from 1863ddb to 701b4c5 Compare July 11, 2026 23:07
@laurinaitis

Copy link
Copy Markdown
Contributor Author

Thanks for the review — both points are addressed in the latest push.

1. Canonical providers nesting. commandProviderNames now enumerates both the canonical tts.providers.<name> / stt.providers.<name> location and the legacy top-level tts.<name> / stt.<name> block (deduped), mirroring the runtime's own dual resolution in _get_named_provider_config (tools/tts_tool.py) and _get_named_stt_provider_config (tools/transcription_tools.py).

The command-provider predicate now also matches _is_command_provider_config: type is optional and case/space-insensitive (absent or normalizing to command), with a required non-empty command — so a canonical block written as { command: "curl …" } with no explicit type: is surfaced too. Fixtures updated to the canonical providers shape, plus legacy, type-less, misconfigured (no command), built-in non-duplication, and providers-container non-leak cases.

2. @/lib/text re-export. Restored import { asText } from '@/lib/text' and the export { asText, includesQuery, prettyName } from '@/lib/text' re-export; the duplicate inline definitions are removed.

All 21 tests in helpers.test.ts pass.

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
@laurinaitis
laurinaitis force-pushed the fix/desktop-tts-provider-dropdown branch from 701b4c5 to 9f8fe84 Compare July 17, 2026 07:49
@laurinaitis

Copy link
Copy Markdown
Contributor Author

Prior art I should have cited when I filed this: #41232 and #40338. Both touch these dropdowns.

#41232 (merged 2026-06-07) added the tts.provider / stt.provider pickers to ENUM_OPTIONS in apps/desktop/src/app/settings/constants.ts. It's a static list, so it can't carry a name that only exists in a user's config.yaml. On main a configured command provider is visible only while it's the active value, through the current-value append at apps/desktop/src/app/settings/helpers.ts:147-149. Switch away and it drops off.

#40338 (@lost9999, opened 2026-06-06) targets the same gap and predates this PR by five weeks. It changes the schema in hermes_cli/web_server.py, which doesn't reach the desktop picker: ConfigSettings passes enumOptionsFor(...) at config-settings.tsx:468-469, and ConfigField resolves enumOptions ?? schema.options at :119, so for these two keys the client-side ENUM_OPTIONS always wins and the server options are never read. The two lists have already drifted - web_server.py offers 4 TTS providers, constants.ts offers 10.

The sweeper review on #40338 (2026-07-14) reached the same conclusion and asked for the merge to be done in the desktop resolver, covering configured-but-inactive command providers. That's what this PR does. I filed it on 07-11, before that review existed, so the timing is coincidence rather than a response to it.

The overlap is in intent and the surfaces are disjoint: #40338 fixes the server schema and plugin names, this fixes the desktop resolver. Both can land. @lost9999 raised it first - if you'd rather carry one PR, I'm happy to fold this diff into #40338 or close this in its favour. Your call on the vehicle.

Scope here is providers declared in config.yaml. Plugin-registered providers are the other half of #40338's ask; those names live in the runtime registry (hermes_cli/plugins.py), and surfacing them needs a backend API, so I've left them out of this diff.

Also pushed since the last review. Built-in names are now excluded using the runtime's own sets instead of the display list. ENUM_OPTIONS omits deepinfra (TTS) and deepinfra / local_command (STT), so a providers.deepinfra command block was offered as selectable while _resolve_command_provider_config (tools/tts_tool.py:472) rejects built-in names and dispatches to the native backend. The guard is case-insensitive to match provider.lower().strip(), so a providers.EDGE block isn't offered either. Two regression tests cover both, and they fail on the previous filter.

Rebased onto main - the branch was 809 commits behind, which left the desktop settings suite red for unrelated reasons. Settings suite is 70 passing, eslint clean on the changed lines.

Separately: the ENUM_OPTIONS staleness above is a live bug on main independent of this PR (deepinfra missing from the TTS dropdown, deepinfra and local_command from STT). Happy to send that as its own one-line PR if useful.

@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #67209 — your commit was cherry-picked onto current main with your authorship preserved in git log.

We verified the guard sets against the current runtime (BUILTIN_TTS_PROVIDERS / BUILTIN_STT_PROVIDERS) and everything matched. Thanks for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/tts Text-to-speech and transcription type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants