diff --git a/contributors/emails/lamjj622009225@gmail.com b/contributors/emails/lamjj622009225@gmail.com new file mode 100644 index 000000000000..ac602b27a288 --- /dev/null +++ b/contributors/emails/lamjj622009225@gmail.com @@ -0,0 +1 @@ +lost9999 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index df93ba39b61f..ba8c8ec7c8cd 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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.`` block. + 1. Command-type providers declared under the canonical + ``.providers.`` location, plus the legacy top-level + ``.`` 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 ``.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 = { diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index be9193bcb0ba..204c1424d3b5 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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. 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. 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.`` 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