fix(web): compute voice provider schema options per-request, align guards with desktop (#40338 follow-up)

Refactor the cherry-picked #40338 backend half:

- Move option merging from import-time _SCHEMA_OVERRIDES mutation to a
  per-request overlay in GET /api/config/schema — options now reflect the
  current config.yaml (no restart needed) and the module-level
  CONFIG_SCHEMA is never mutated. The endpoint gains an optional
  ?profile= param scoped via _config_profile_scope.
- Keep builtin display order first, customs appended (drop the
  sorted(set(...)) re-sort) — matches desktop enumOptionsFor.
- Only command-type provider blocks count (type absent or 'command' plus
  non-empty command string), enumerated from the canonical
  <kind>.providers.* location AND the legacy top-level <kind>.<name>
  fallback — the same dual resolution as _get_named_provider_config /
  _get_named_stt_provider_config. Builtin-name collisions are excluded
  case-insensitively against the RUNTIME builtin sets (not the display
  shortlist), mirroring apps/desktop/src/app/settings/helpers.ts
  commandProviderNames (#67209).
- Drop the plugin.yaml 'provides: [tts]' manifest scan — that convention
  does not exist (manifests carry provides_tools/provides_hooks only);
  plugin TTS/STT providers register at runtime via
  ctx.register_tts_provider(). Instead, opportunistically include names
  from agent.tts_registry / agent.transcription_registry when plugins
  happen to be loaded in this process.
- Current tts.provider/stt.provider value preserved in options.
- Tests: custom command provider merge (tts+stt), builtin-order
  preservation, EDGE collision exclusion, non-command block exclusion,
  current-value preservation, per-request freshness, legacy top-level
  block support.
This commit is contained in:
teknium1 2026-07-19 03:32:43 -07:00 committed by Teknium
parent 1e17492784
commit 3fc006ebe1
3 changed files with 254 additions and 72 deletions

View file

@ -0,0 +1 @@
lost9999

View file

@ -862,91 +862,152 @@ for _k, _v in CONFIG_SCHEMA.items():
CONFIG_SCHEMA = _ordered_schema
def _custom_provider_options(kind: str, builtin_names: List[str]) -> List[str]:
def _is_command_provider_block(value: Any) -> bool:
"""Return True when *value* declares a command-type voice provider.
Mirrors the runtime discriminators
(``tools.tts_tool._is_command_provider_config`` /
``tools.transcription_tools._is_command_stt_provider_config``) and the
desktop's ``isCommandProvider`` in
``apps/desktop/src/app/settings/helpers.ts``: ``type`` is OPTIONAL and
case/space-insensitive (absent or normalizing to ``"command"``), and
``command`` MUST be a non-empty string. Built-in blocks (which carry
``voice``/``model`` and no ``command``) and the ``providers`` container
itself are rejected.
"""
if not isinstance(value, dict):
return False
ptype = str(value.get("type") or "").strip().lower()
if ptype and ptype != "command":
return False
command = value.get("command")
return isinstance(command, str) and bool(command.strip())
def _custom_provider_options(
kind: str,
builtin_names: List[str],
cfg: Dict[str, Any],
) -> List[str]:
"""Return a merged provider option list without hard-coding vendor names.
*kind* is ``"tts"`` or ``"stt"``. The result keeps the built-in names
first, then appends:
*kind* is ``"tts"`` or ``"stt"``. The result keeps the built-in display
names first (original order NOT re-sorted), then appends:
1. ``tts.providers.*`` / ``stt.providers.*`` keys declared in
``config.yaml`` (these are command-type providers the user already
configured).
2. Any additional ``kind.provider`` value found on disk that is not yet
in the list this covers custom names that only appear as the active
provider without a dedicated ``providers.<name>`` block.
1. Command-type providers declared under the canonical
``<kind>.providers.<name>`` location, plus the legacy top-level
``<kind>.<name>`` fallback exactly the dual resolution the runtime
performs in ``_get_named_provider_config`` /
``_get_named_stt_provider_config``. Names colliding with a RUNTIME
built-in are excluded case-insensitively (the runtime rejects a
built-in name as a command provider before any config lookup), so a
``providers.EDGE`` command block is not offered.
2. Plugin-registered provider names from ``agent.tts_registry`` /
``agent.transcription_registry`` opportunistic only: plugins
register at runtime via ``ctx.register_tts_provider()``, and this
process does not necessarily call ``discover_plugins()``, so the
registry may legitimately be empty here. (There is no static
``provides: [tts]`` manifest convention to scan real manifests only
carry ``provides_tools``/``provides_hooks``.)
3. The current ``<kind>.provider`` value when not already present a
custom name that only appears as the active provider stays
selectable (matches desktop ``enumOptionsFor``'s current-value
preservation).
Guard semantics deliberately mirror
``apps/desktop/src/app/settings/helpers.ts:commandProviderNames`` so the
backend schema (web dashboard) and the desktop client agree on which
names are offered.
"""
names = [str(n) for n in builtin_names]
seen = {n.strip().lower() for n in names}
names = list(builtin_names)
seen = {n.lower() for n in names}
# Guard against the RUNTIME built-in sets, not the display shortlist
# above: the display list drifts from the runtime sets (e.g. omits
# ``deepinfra``), and filtering on it would offer names the runtime
# would never honour as command providers.
if kind == "tts":
from tools.tts_tool import BUILTIN_TTS_PROVIDERS as _runtime_builtins
else:
from tools.transcription_tools import BUILTIN_STT_PROVIDERS as _runtime_builtins
def _add(name: str) -> None:
if isinstance(name, str):
normalized = name.strip().lower()
if normalized and normalized not in seen:
names.append(name.strip())
seen.add(normalized)
def _add(name: Any) -> None:
if not isinstance(name, str):
return
stripped = name.strip()
key = stripped.lower()
if stripped and key not in seen:
names.append(stripped)
seen.add(key)
cfg = load_config()
cfg_provider = cfg_get(cfg, f"{kind}.provider")
_add(cfg_provider)
section = cfg.get(kind)
if not isinstance(section, dict):
section = {}
providers_map = cfg.get(kind, {}).get("providers") or {}
# Canonical nested location first, then the legacy top-level fallback —
# the same order the runtime resolves them in.
candidate_blocks: List[Any] = []
providers_map = section.get("providers")
if isinstance(providers_map, dict):
for name in providers_map.keys():
_add(name)
candidate_blocks.append(providers_map)
candidate_blocks.append(
{k: v for k, v in section.items() if k != "providers"}
)
for block in candidate_blocks:
for name, value in block.items():
if (
isinstance(name, str)
and name.strip().lower() not in _runtime_builtins
and _is_command_provider_block(value)
):
_add(name)
# Plugin-registered providers (only populated when plugins are loaded in
# this process). Registry names can never collide with built-ins — the
# registries reject such registrations.
try:
if kind == "tts":
from agent.tts_registry import list_providers as _list_voice_providers
else:
from agent.transcription_registry import list_providers as _list_voice_providers
for _p in _list_voice_providers():
_add(getattr(_p, "name", None))
except Exception: # pragma: no cover - registry import should not break schema
pass
# Current-value preservation (``cfg_get`` takes *keys*, not dotted paths).
_add(cfg_get(cfg, kind, "provider"))
return names
def _installed_tts_plugin_names() -> List[str]:
"""Return ``name`` values from user-installed TTS plugin manifests."""
def _schema_with_voice_provider_options() -> Dict[str, Dict[str, Any]]:
"""Return CONFIG_SCHEMA with per-request voice provider options merged.
plugin_dir = get_hermes_home() / "plugins"
names: List[str] = []
if not plugin_dir.is_dir():
return names
cfg = load_config()
enabled = set()
raw_enabled = (cfg.get("plugins") or {}).get("enabled") or []
if isinstance(raw_enabled, list):
for item in raw_enabled:
if isinstance(item, str):
enabled.add(item.strip().lower())
for child in sorted(plugin_dir.iterdir()):
manifest = child / "plugin.yaml"
if not manifest.is_file():
Computed at request time (not import time) so options reflect the
CURRENT config.yaml including providers added after the server
started, and the profile-scoped config when the request carries a
``profile`` param. The module-level ``CONFIG_SCHEMA`` is never mutated;
entries that change are shallow-copied onto a copied mapping.
"""
try:
cfg = load_config()
except Exception: # pragma: no cover - schema must survive config errors
return CONFIG_SCHEMA
overlay: Dict[str, Dict[str, Any]] = {}
for kind in ("tts", "stt"):
key = f"{kind}.provider"
entry = CONFIG_SCHEMA.get(key)
if not isinstance(entry, dict) or not isinstance(entry.get("options"), list):
continue
if child.name.lower() not in enabled:
continue
try:
data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
except Exception:
continue
provides = data.get("provides") or []
if "tts" in provides:
name = str(data.get("name") or child.name).strip()
if name:
names.append(name)
return names
# Dynamically surface user-configured / plugin-backed provider names so the
# Desktop settings page does not silently drop custom providers.
for _audio_kind in ("tts", "stt"):
_key = f"{_audio_kind}.provider"
_override = _SCHEMA_OVERRIDES.get(_key)
if isinstance(_override, dict) and isinstance(_override.get("options"), list):
_builtin_names = list(_override["options"])
_custom_names = _custom_provider_options(_audio_kind, _builtin_names)
if _audio_kind == "tts":
_custom_names.extend(_installed_tts_plugin_names())
_override["options"] = sorted(set(_custom_names), key=str.lower)
merged = _custom_provider_options(kind, list(entry["options"]), cfg)
if merged != entry["options"]:
overlay[key] = {**entry, "options": merged}
if not overlay:
return CONFIG_SCHEMA
fields = dict(CONFIG_SCHEMA)
fields.update(overlay)
return fields
class ConfigUpdate(BaseModel):
@ -5876,8 +5937,13 @@ async def get_defaults():
@app.get("/api/config/schema")
async def get_schema():
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
async def get_schema(profile: Optional[str] = None):
# Voice provider options are merged per-request so user-declared
# command providers (tts.providers.* / stt.providers.*) added after
# server start still show up, scoped to the requested profile's config.
with _config_profile_scope(profile):
fields = _schema_with_voice_provider_options()
return {"fields": fields, "category_order": _CATEGORY_ORDER}
_EMPTY_MODEL_INFO: dict = {

View file

@ -2589,6 +2589,121 @@ class TestWebServerEndpoints:
assert len(data["category_order"]) > 0
assert "general" in data["category_order"]
def _schema_provider_options(self, key):
resp = self.client.get("/api/config/schema")
assert resp.status_code == 200
return resp.json()["fields"][key]["options"]
def test_config_schema_merges_custom_command_tts_provider(self):
"""A tts.providers.<name> command block appears in tts.provider options,
appended AFTER the built-ins (original order preserved, no re-sort)."""
from hermes_cli.config import load_config, save_config
from hermes_cli.web_server import CONFIG_SCHEMA
builtins = list(CONFIG_SCHEMA["tts.provider"]["options"])
cfg = load_config()
cfg.setdefault("tts", {}).setdefault("providers", {})["mycustomtts"] = {
"type": "command",
"command": "mytts --text {text} --out {output}",
}
save_config(cfg)
options = self._schema_provider_options("tts.provider")
assert options[: len(builtins)] == builtins # built-in order kept
assert "mycustomtts" in options
assert options.count("mycustomtts") == 1
# The module-level schema must NOT have been mutated.
assert "mycustomtts" not in CONFIG_SCHEMA["tts.provider"]["options"]
def test_config_schema_merges_custom_command_stt_provider(self):
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("stt", {}).setdefault("providers", {})["mywhisper"] = {
"command": "whisper-cli {input}", # type: omitted → command implied
}
save_config(cfg)
options = self._schema_provider_options("stt.provider")
assert "mywhisper" in options
def test_config_schema_excludes_builtin_name_collisions(self):
"""A providers.EDGE command block must NOT be offered — the runtime
rejects built-in names as command providers (case-insensitively)."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("tts", {}).setdefault("providers", {})["EDGE"] = {
"type": "command",
"command": "fake-edge {text}",
}
save_config(cfg)
options = self._schema_provider_options("tts.provider")
lowered = [o.lower() for o in options]
assert lowered.count("edge") == 1 # only the built-in entry
def test_config_schema_excludes_non_command_blocks(self):
"""Built-in-shaped blocks (voice/model, no command) and non-dicts are
not offered as providers."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
tts = cfg.setdefault("tts", {})
tts.setdefault("providers", {})["notacommand"] = {"voice": "en-US-Foo"}
tts["stringy"] = "oops"
save_config(cfg)
options = self._schema_provider_options("tts.provider")
assert "notacommand" not in options
assert "stringy" not in options
def test_config_schema_preserves_current_custom_provider_value(self):
"""A custom active tts.provider without a providers.<name> block stays
selectable (current-value preservation, matching desktop behavior)."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("tts", {})["provider"] = "orphancustom"
save_config(cfg)
options = self._schema_provider_options("tts.provider")
assert "orphancustom" in options
def test_config_schema_reflects_config_changes_without_restart(self):
"""Options are computed per-request — adding a provider after the
first schema fetch shows up on the next fetch."""
from hermes_cli.config import load_config, save_config
before = self._schema_provider_options("tts.provider")
assert "latecomer" not in before
cfg = load_config()
cfg.setdefault("tts", {}).setdefault("providers", {})["latecomer"] = {
"type": "command",
"command": "late {text}",
}
save_config(cfg)
after = self._schema_provider_options("tts.provider")
assert "latecomer" in after
def test_config_schema_legacy_toplevel_command_provider(self):
"""The legacy top-level ``tts.<name>`` command block (runtime
back-compat fallback) is also offered."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("tts", {})["legacytts"] = {
"type": "command",
"command": "legacy {text}",
}
save_config(cfg)
options = self._schema_provider_options("tts.provider")
assert "legacytts" in options
def test_get_config_defaults(self):
resp = self.client.get("/api/config/defaults")
assert resp.status_code == 200