Release v0.51.270 — Release IL (stage-u1 — un-hold batch: #3517 #3624 #3613) (#3674)

* feat(commands): add /use to force a skill for the next turn (#3517, #2977)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(auth): cap pending passkey challenges by evicting oldest, not rejecting (#3624)

Co-authored-by: Hinotobi <paperlantern.agent@gmail.com>

* fix(providers): expose model-provider plugins in WebUI (#3613)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* docs(changelog): v0.51.270 — Release IL (stage-u1, 3-PR un-hold batch; #3448 + #3618 dropped)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hinotobi <paperlantern.agent@gmail.com>
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-05 11:35:26 -07:00
1 parent 2c7b530071
commit 58528a4d88
15 files changed
+792 -26

No files matched your search

+9
View File
@@ -3,6 +3,15 @@
## [Unreleased]
## [v0.51.270] — 2026-06-05 — Release IL (stage-u1 — un-hold batch: author-fixed PRs re-gated)
### Fixed
- **The pending passkey-challenge cap now evicts the oldest challenges instead of rejecting new ones**, so the anti-unbounded-growth bound can no longer be turned into a lockout DoS (filling the global or per-context cap previously blocked all legitimate registration/login until TTL expiry). (#3624, @Hinotoi-agent)
- **Model-provider plugins (e.g. Yandex) are now surfaced in the WebUI providers panel** — env-var name and configured status only, never the value — without regressing existing custom-provider handling. (#3613, @pamnard)
### Added
- **`/use <skill>` slash command** forces a specific skill for the next turn. The directive resolves as part of the next real send (awaiting the in-flight `/api/skills` lookup) and clears only once consumed, so a fast follow-up send can't apply a stale directive to the wrong message. (#3517, @rodboev; implements #2977)
## [v0.51.269] — 2026-06-05 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl)
### Fixed
+19 -3
View File
@@ -27,6 +27,10 @@ from urllib.parse import parse_qs, urlparse
# ── Basic layout ──────────────────────────────────────────────────────────────
import api.paths as _paths
from api.plugin_providers import (
effective_provider_display_name as _effective_provider_display_name,
is_plugin_model_provider as _is_plugin_model_provider,
)
HOME = _paths.HOME
_hermes_home_has_webui_state = _paths._hermes_home_has_webui_state
@@ -3198,6 +3202,12 @@ def invalidate_models_cache():
# Also delete the disk cache so the next cold build starts fresh.
# Disk delete is outside the lock — file I/O shouldn't block other readers.
_delete_models_cache_on_disk()
try:
from api.plugin_providers import invalidate_plugin_model_provider_cache
invalidate_plugin_model_provider_cache()
except Exception:
pass
def invalidate_credential_pool_cache(provider_id: str):
@@ -3787,7 +3797,9 @@ def get_available_models() -> dict:
# aliases; ``isinstance(_provider_cfg, dict)`` accepts custom
# entries that supply their own models/api_key/base_url. (#2399)
_is_known_provider = (
_canonical in _PROVIDER_MODELS or _canonical in _PROVIDER_DISPLAY
_canonical in _PROVIDER_MODELS
or _canonical in _PROVIDER_DISPLAY
or _is_plugin_model_provider(_canonical)
)
_is_provider_config = isinstance(_provider_cfg, dict)
if not (_is_known_provider or _is_provider_config):
@@ -4242,7 +4254,7 @@ def get_available_models() -> dict:
group["models_endpoint_error"] = _named_custom_errors[pid]
groups.append(group)
continue
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
provider_name = _effective_provider_display_name(pid, _PROVIDER_DISPLAY)
if pid == "openrouter":
# OpenRouter has two model surfaces:
# (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models()
@@ -4545,7 +4557,11 @@ def get_available_models() -> dict:
"models": models,
}
)
elif pid in _PROVIDER_MODELS or pid in _canonical_to_raw_provider_key:
elif (
pid in _PROVIDER_MODELS
or pid in _canonical_to_raw_provider_key
or _is_plugin_model_provider(pid)
):
# Look up provider_cfg using the original raw key from
# config.yaml so that mixed-case / underscore keys like
# ``CLIPpoxy`` or ``snake_case_provider`` still resolve
+41 -1
View File
@@ -12,6 +12,7 @@ import json
import os
import secrets
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
@@ -29,7 +30,10 @@ except Exception: # pragma: no cover - exercised by source tests instead
_CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
_CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.json"
_CHALLENGE_TTL = 300
_CHALLENGE_TTL = 90
_MAX_CHALLENGES = 128
_MAX_CHALLENGES_PER_CONTEXT = 8
_CHALLENGES_LOCK = threading.Lock()
_RP_NAME = "Hermes WebUI"
@@ -37,6 +41,10 @@ class PasskeyError(ValueError):
"""Raised for user-correctable WebAuthn failures."""
class PasskeyRateLimitError(PasskeyError):
"""Raised when too many outstanding WebAuthn challenges are pending."""
def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
@@ -104,6 +112,8 @@ def passkeys_available() -> bool:
def _load_challenges() -> dict[str, dict[str, Any]]:
# May prune and rewrite the challenge file; callers that mutate the store
# must hold _CHALLENGES_LOCK across load→mutate→write.
raw = _json_load(_CHALLENGES_FILE, {})
if not isinstance(raw, dict):
return {}
@@ -117,13 +127,43 @@ def _load_challenges() -> dict[str, dict[str, Any]]:
return clean
def _oldest_challenge_key(data: dict[str, dict[str, Any]], keys: list[str]) -> str | None:
if not keys:
return None
return min(keys, key=lambda k: float(data.get(k, {}).get("ts", 0)))
def _evict_oldest_challenges(data: dict[str, dict[str, Any]], kind: str, rp_id: str, origin: str) -> None:
"""Keep the challenge store bounded while admitting the newest challenge."""
while True:
same_context = [
k for k, v in data.items()
if v.get("kind") == kind and v.get("rp_id") == rp_id and v.get("origin") == origin
]
if len(same_context) < _MAX_CHALLENGES_PER_CONTEXT:
break
oldest = _oldest_challenge_key(data, same_context)
if oldest is None:
break
data.pop(oldest, None)
while len(data) >= _MAX_CHALLENGES:
oldest = _oldest_challenge_key(data, list(data))
if oldest is None:
break
data.pop(oldest, None)
def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
with _CHALLENGES_LOCK:
data = _load_challenges()
_evict_oldest_challenges(data, kind, rp_id, origin)
data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
_atomic_write_json(_CHALLENGES_FILE, data)
def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
with _CHALLENGES_LOCK:
data = _load_challenges()
entry = data.pop(challenge, None)
_atomic_write_json(_CHALLENGES_FILE, data)
+141
View File
@@ -0,0 +1,141 @@
"""Helpers for model-provider plugins (``plugins/model-providers/<name>/``).
The Hermes agent discovers these via ``providers.list_providers()`` and exposes
them in the CLI model picker. WebUI must mirror that registry instead of
relying only on the static ``_PROVIDER_DISPLAY`` / ``_PROVIDER_MODELS`` tables.
Bundled agent profiles (gemini, nous, custom, …) also live in
``list_providers()``. WebUI already handles those via static tables and
dedicated code paths — only *plugin-only* slugs (e.g. user-installed yandex)
should take the plugin discovery path.
"""
from __future__ import annotations
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
_PROFILES_LOCK = threading.Lock()
_PROFILES_BY_NAME: dict[str, Any] | None = None
_WEBUI_STATIC_PROVIDER_IDS: frozenset[str] | None = None
def _webui_static_provider_ids() -> frozenset[str]:
"""Provider slugs already owned by WebUI static tables / special cases."""
global _WEBUI_STATIC_PROVIDER_IDS
if _WEBUI_STATIC_PROVIDER_IDS is not None:
return _WEBUI_STATIC_PROVIDER_IDS
try:
from api.config import _PROVIDER_DISPLAY, _PROVIDER_MODELS
static = (
frozenset(_PROVIDER_DISPLAY.keys())
| frozenset(_PROVIDER_MODELS.keys())
| frozenset({"custom"})
)
except Exception:
static = frozenset({"custom"})
_WEBUI_STATIC_PROVIDER_IDS = static
return static
def _load_profiles_by_name() -> dict[str, Any]:
try:
from providers import list_providers
except Exception:
logger.debug("providers package unavailable for plugin discovery", exc_info=True)
return {}
result: dict[str, Any] = {}
try:
for profile in list_providers():
name = str(getattr(profile, "name", "") or "").strip().lower()
if name:
result[name] = profile
except Exception:
logger.debug("Failed to enumerate model-provider plugins", exc_info=True)
return {}
return result
def plugin_model_provider_profiles() -> dict[str, Any]:
"""Return registered model-provider profiles keyed by canonical slug."""
global _PROFILES_BY_NAME
cached = _PROFILES_BY_NAME
if cached is not None:
return cached
with _PROFILES_LOCK:
if _PROFILES_BY_NAME is None:
_PROFILES_BY_NAME = _load_profiles_by_name()
return _PROFILES_BY_NAME
def invalidate_plugin_model_provider_cache() -> None:
"""Clear cached plugin discovery (e.g. after config reload)."""
global _PROFILES_BY_NAME
with _PROFILES_LOCK:
_PROFILES_BY_NAME = None
def plugin_model_provider_ids() -> frozenset[str]:
"""Slugs from ``list_providers()`` that are not already WebUI-static."""
static = _webui_static_provider_ids()
return frozenset(
pid for pid in plugin_model_provider_profiles().keys() if pid not in static
)
def plugin_model_provider_display_name(provider_id: str) -> str | None:
profile = plugin_model_provider_profiles().get((provider_id or "").strip().lower())
if profile is None:
return None
return str(getattr(profile, "display_name", "") or getattr(profile, "name", "") or "").strip() or None
def plugin_model_provider_api_key_env_var(provider_id: str) -> str | None:
"""Return the primary API-key env var for a plugin provider, if any."""
profile = plugin_model_provider_profiles().get((provider_id or "").strip().lower())
if profile is None:
return None
env_vars = getattr(profile, "env_vars", ()) or ()
for var in env_vars:
upper = str(var).upper()
if upper.endswith("_BASE_URL") or upper.endswith("_URL"):
continue
if upper.endswith("_FOLDER_ID"):
continue
return str(var)
return None
def effective_provider_env_var(provider_id: str, static_map: dict[str, str]) -> str | None:
pid = (provider_id or "").strip().lower()
if not pid:
return None
if pid in static_map:
return static_map[pid]
if not is_plugin_model_provider(pid):
return None
return plugin_model_provider_api_key_env_var(pid)
def effective_provider_display_name(provider_id: str, static_map: dict[str, str]) -> str:
pid = (provider_id or "").strip().lower()
if pid in static_map:
return static_map[pid]
if is_plugin_model_provider(pid):
plugin_name = plugin_model_provider_display_name(pid)
if plugin_name:
return plugin_name
return pid.replace("-", " ").title()
def is_plugin_model_provider(provider_id: str) -> bool:
"""True for plugin-only providers (not already in WebUI static tables)."""
pid = (provider_id or "").strip().lower()
if not pid or pid in _webui_static_provider_ids():
return False
return pid in plugin_model_provider_profiles()
+66 -15
View File
@@ -43,10 +43,21 @@ from api.config import (
invalidate_models_cache,
reload_config,
)
from api.plugin_providers import (
effective_provider_display_name,
effective_provider_env_var,
is_plugin_model_provider,
plugin_model_provider_ids,
)
logger = logging.getLogger(__name__)
def _provider_env_var_for(provider_id: str) -> str | None:
"""Resolve the API-key env var for a provider (static table + plugin profiles)."""
return effective_provider_env_var(provider_id, _PROVIDER_ENV_VAR)
def _custom_provider_name_matches(provider_id: str, name: object) -> bool:
"""Return True when *provider_id* refers to a named custom provider."""
pid = str(provider_id or "").strip().lower()
@@ -782,7 +793,7 @@ def _provider_has_shadowed_codex_oauth_value(provider_id: str) -> bool:
if (provider_id or "").strip().lower() != "openai":
return False
values: list[object] = []
env_var = _PROVIDER_ENV_VAR.get(provider_id)
env_var = _provider_env_var_for(provider_id)
if env_var:
env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path)
@@ -923,7 +934,7 @@ def _provider_has_key(provider_id: str) -> bool:
4. ``config.yaml → providers.<id>.api_key``
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
"""
env_var = _PROVIDER_ENV_VAR.get(provider_id)
env_var = _provider_env_var_for(provider_id)
if env_var:
env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path)
@@ -973,7 +984,7 @@ def _provider_has_key(provider_id: str) -> bool:
def _get_provider_api_key(provider_id: str) -> str | None:
"""Return a configured provider API key without exposing it to callers."""
provider_id = (provider_id or "").strip().lower()
env_var = _PROVIDER_ENV_VAR.get(provider_id)
env_var = _provider_env_var_for(provider_id)
if env_var:
env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path)
@@ -1129,7 +1140,7 @@ def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None
if value:
env[key] = value
env_var = _PROVIDER_ENV_VAR.get((provider or "").strip().lower())
env_var = _provider_env_var_for((provider or "").strip().lower())
if env_var and api_key:
env[env_var] = api_key
@@ -1752,6 +1763,7 @@ def get_providers() -> dict[str, Any]:
# Collect all known provider IDs from multiple sources
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
known_ids.update(plugin_model_provider_ids())
# Also detect providers from config.yaml providers section
cfg = get_config()
@@ -1763,9 +1775,21 @@ def get_providers() -> dict[str, Any]:
known_ids.update(_OAUTH_PROVIDERS)
for pid in sorted(known_ids):
display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
display_name = effective_provider_display_name(pid, _PROVIDER_DISPLAY)
is_oauth = _provider_is_oauth(pid)
has_key = _provider_has_key(pid)
plugin_auth_status: dict[str, Any] | None = None
if not has_key and is_plugin_model_provider(pid):
try:
from hermes_cli.auth import get_auth_status as _gas_plugin
_plugin_status = _gas_plugin(pid)
if isinstance(_plugin_status, dict) and (
_plugin_status.get("logged_in") or _plugin_status.get("configured")
):
has_key = True
plugin_auth_status = _plugin_status
except Exception:
logger.debug("Plugin provider auth check failed for %s", pid, exc_info=True)
# Determine key source
key_source = "none"
@@ -1797,7 +1821,7 @@ def get_providers() -> dict[str, Any]:
logger.debug("hermes_cli auth check failed for %s", pid, exc_info=True)
# keep has_key from _provider_has_key()
elif has_key:
env_var = _PROVIDER_ENV_VAR.get(pid)
env_var = _provider_env_var_for(pid)
if env_var:
env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path)
@@ -1821,19 +1845,29 @@ def get_providers() -> dict[str, Any]:
aliased = True
break
if not aliased:
key_source = "config_yaml"
_plugin_ks = (
str(plugin_auth_status.get("key_source") or "").strip()
if isinstance(plugin_auth_status, dict)
else ""
)
key_source = _plugin_ks or "config_yaml"
else:
key_source = "config_yaml"
elif pid not in _PROVIDER_ENV_VAR:
_plugin_ks = (
str(plugin_auth_status.get("key_source") or "").strip()
if isinstance(plugin_auth_status, dict)
else ""
)
key_source = _plugin_ks or "config_yaml"
elif not _provider_env_var_for(pid):
# Fallback: provider is not a known API-key provider and not in
# the hardcoded _OAUTH_PROVIDERS set. It may be a custom or
# newly-added OAuth provider (e.g. Anthropic connected via OAuth).
# Check live auth status so the Providers tab agrees with the
# model picker (#1212).
#
# IMPORTANT: we skip providers in _PROVIDER_ENV_VAR because they
# are pure API-key providers — calling get_auth_status() for every
# unconfigured API-key provider would add unnecessary latency
# IMPORTANT: we skip providers with a known API-key env var because
# they are pure API-key providers — calling get_auth_status() for
# every unconfigured API-key provider would add unnecessary latency
# (network round-trip per provider) on the Settings page.
# Validate pid looks like a real provider before probing
import re as _re
@@ -1924,6 +1958,21 @@ def get_providers() -> dict[str, Any]:
models_total = len(models)
except Exception:
logger.debug("Failed to load LM Studio models from hermes_cli")
if is_plugin_model_provider(pid):
try:
live_models = _models_from_live_provider_ids(
pid,
_read_live_provider_model_ids(pid),
)
if live_models:
models = live_models
models_total = len(models)
except Exception:
logger.debug(
"Failed to load plugin model-provider catalog for %s",
pid,
exc_info=True,
)
# Also include models from config.yaml providers section
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(pid, {})
@@ -1941,11 +1990,13 @@ def get_providers() -> dict[str, Any]:
if pid != "nous":
models_total = len(models)
_is_plugin = is_plugin_model_provider(pid)
providers.append({
"id": pid,
"display_name": display_name,
"has_key": has_key,
"configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
"configurable": not is_oauth and bool(_provider_env_var_for(pid)),
"is_plugin_provider": _is_plugin,
"is_oauth": is_oauth,
"key_source": key_source,
"auth_error": auth_error,
@@ -2042,11 +2093,11 @@ def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
f"Use `hermes model` in the terminal to configure it.",
}
env_var = _PROVIDER_ENV_VAR.get(provider_id)
env_var = _provider_env_var_for(provider_id)
if not env_var:
return {
"ok": False,
"error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
"error": f"Cannot configure API key for '{effective_provider_display_name(provider_id, _PROVIDER_DISPLAY)}'. "
f"This provider does not have a known env var mapping.",
}
+9 -2
View File
@@ -8024,7 +8024,7 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/auth/passkey/options":
from api.auth import _passkey_feature_flag_enabled, is_auth_enabled
from api.passkeys import PasskeyError, authentication_options
from api.passkeys import PasskeyError, PasskeyRateLimitError, authentication_options
if not _passkey_feature_flag_enabled():
return j(handler, {"error": "Passkey support is disabled. Set HERMES_WEBUI_PASSKEY=1 or webui_passkey_enabled: true to enable."}, status=404)
@@ -8032,6 +8032,8 @@ def handle_post(handler, parsed) -> bool:
return j(handler, {"error": "Auth not enabled"}, status=400)
try:
return j(handler, {"ok": True, "publicKey": authentication_options(handler)})
except PasskeyRateLimitError as e:
return bad(handler, str(e), status=429)
except PasskeyError as e:
return bad(handler, str(e), status=400)
@@ -8064,11 +8066,16 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/auth/passkey/register/options":
from api.auth import _passkey_feature_flag_enabled
from api.passkeys import registration_options
from api.passkeys import PasskeyError, PasskeyRateLimitError, registration_options
if not _passkey_feature_flag_enabled():
return j(handler, {"error": "Passkey support is disabled."}, status=404)
try:
return j(handler, {"ok": True, "publicKey": registration_options(handler)})
except PasskeyRateLimitError as e:
return bad(handler, str(e), status=429)
except PasskeyError as e:
return bad(handler, str(e), status=400)
if parsed.path == "/api/auth/passkey/register":
from api.auth import _passkey_feature_flag_enabled
+40
View File
@@ -17,6 +17,7 @@ const COMMANDS=[
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true},
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
{name:'use', desc:t('cmd_use'), fn:cmdUse, arg:'skill-name', subArgs:'skills', noEcho:true},
{name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true},
{name:'goal', desc:t('cmd_goal'), fn:cmdGoal, arg:'[status|pause|resume|clear|text]', subArgs:['status','pause','resume','clear']},
{name:'queue', desc:t('cmd_queue'), fn:cmdQueue, arg:'message', noEcho:true},
@@ -94,6 +95,7 @@ function getMatchingCommands(prefix){
return matches;
}
let _forcedSkillDirectivePending=null;
let _slashModelCache=null;
let _slashModelCachePromise=null;
let _slashPersonalityCache=null;
@@ -912,6 +914,44 @@ async function cmdSkills(args){
}
}
async function cmdUse(args){
if(!args){
S.messages.push({role:'assistant',content:'Usage: `/use <skill-name>` — forces the agent to consult that skill before its next response.'});
renderMessages();
return;
}
let resolve;
const pending = {sessionId:S.session&&S.session.session_id||null,promise:null};
pending.promise = new Promise(r => { resolve = r; });
_forcedSkillDirectivePending = pending;
const isCurrentSession = () => !pending.sessionId || (S.session&&S.session.session_id)===pending.sessionId;
try{
const data = await api('/api/skills');
const skills = data.skills || [];
const match = skills.find(s => (s.name||'').toLowerCase() === args.toLowerCase());
if(!match){
resolve(null);
if(_forcedSkillDirectivePending===pending)_forcedSkillDirectivePending = null;
if(isCurrentSession()){
const msg = {role:'assistant', content:`No skill named \`${args}\`. Use \`/skills\` to see available skills.`};
S.messages.push(msg); renderMessages();
}
return;
}
const directive = `[USER OVERRIDE] You MUST consult skill '${match.name}' via skill_view before responding to the next message.`;
resolve(directive);
if(isCurrentSession()){
S.messages.push({role:'assistant', content:`Next turn: skill \`${match.name}\` will be forced.`});
renderMessages();
}
showToast(`Skill \`${match.name}\` will be used for next turn.`);
}catch(e){
resolve(null);
if(_forcedSkillDirectivePending===pending)_forcedSkillDirectivePending = null;
showToast('Failed to load skills: '+e.message);
}
}
async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;}
if(!args){
+12
View File
@@ -205,6 +205,7 @@ const LOCALES = {
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills',
cmd_use: 'Force a skill for the next message',
available_commands: 'Available commands:',
type_slash: 'Type / to see commands',
conversation_cleared: 'Conversation cleared',
@@ -1541,6 +1542,7 @@ const LOCALES = {
cmd_theme: 'Cambia aspetto (tema: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: "Cambia personalità dell'agente",
cmd_skills: 'Elenca le skill Hermes disponibili',
cmd_use: 'Forza una skill per il prossimo messaggio',
available_commands: 'Comandi disponibili:',
type_slash: 'Digita / per vedere i comandi',
conversation_cleared: 'Conversazione cancellata',
@@ -2869,6 +2871,7 @@ const LOCALES = {
cmd_theme: '外観を切り替え (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'エージェントのパーソナリティを切り替え',
cmd_skills: '利用可能な Hermes スキルを一覧表示',
cmd_use: '次のメッセージにスキルを強制適用',
available_commands: '利用可能なコマンド:',
type_slash: '/ を入力するとコマンド一覧',
conversation_cleared: '会話をクリアしました',
@@ -4159,6 +4162,7 @@ const LOCALES = {
cmd_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Переключить личность агента',
cmd_skills: 'Показать доступные навыки Hermes',
cmd_use: 'Принудительно применить навык к следующему сообщению',
available_commands: 'Доступные команды:',
type_slash: 'Введите /, чтобы увидеть команды',
conversation_cleared: 'Беседа очищена',
@@ -5458,6 +5462,7 @@ const LOCALES = {
cmd_theme: 'Cambiar apariencia (tema: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Cambiar la personalidad del agente',
cmd_skills: 'Listar las skills de Hermes disponibles',
cmd_use: 'Forzar una skill para el próximo mensaje',
available_commands: 'Comandos disponibles:',
type_slash: 'Escribe / para ver los comandos',
conversation_cleared: 'Conversación borrada',
@@ -6698,6 +6703,7 @@ const LOCALES = {
cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Agenten-Persönlichkeit wechseln',
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
cmd_use: 'Einen Skill für die nächste Nachricht erzwingen',
available_commands: 'Verfügbare Befehle:',
type_slash: 'Tippe / für Befehle',
conversation_cleared: 'Konversation gelöscht',
@@ -7989,6 +7995,7 @@ const LOCALES = {
cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: '切换 Agent 人设',
cmd_skills: '列出可用的 Hermes 技能',
cmd_use: '为下一条消息强制使用技能',
available_commands: '可用命令:',
type_slash: '输入 / 可查看命令',
conversation_cleared: '对话已清空',
@@ -9266,6 +9273,7 @@ const LOCALES = {
cmd_theme: '切換外觀(主題:system/dark/light,skin:default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: '切換 Agent 角色',
cmd_skills: '列出可用的 Hermes 技能',
cmd_use: '為下一則訊息強制使用技能',
available_commands: '可用命令:',
type_slash: '輸入 / 檢視命令',
conversation_cleared: '已清除對話',
@@ -10513,6 +10521,7 @@ const LOCALES = {
cmd_theme: 'Trocar aparência (tema: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Trocar personalidade do agente',
cmd_skills: 'Listar skills disponíveis do Hermes',
cmd_use: 'Forçar uma skill para a próxima mensagem',
available_commands: 'Comandos disponíveis:',
type_slash: 'Digite / para ver comandos',
conversation_cleared: 'Conversa limpa',
@@ -11726,6 +11735,7 @@ const LOCALES = {
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills',
cmd_use: 'Force a skill for the next message',
available_commands: '사용 가능한 명령:',
type_slash: '/ 를 입력해 명령 보기',
conversation_cleared: '대화를 지웠습니다',
@@ -13047,6 +13057,7 @@ const LOCALES = {
cmd_theme: 'Changer d\'apparence (thème : system/dark/light, skin : default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Personnalité de l\'agent de commutation',
cmd_skills: 'Lister les compétences Hermès disponibles',
cmd_use: 'Forcer une compétence pour le prochain message',
available_commands: 'Commandes disponibles :',
type_slash: 'Tapez / pour voir les commandes',
conversation_cleared: 'Conversation effacée',
@@ -14321,6 +14332,7 @@ const LOCALES = {
cmd_theme: 'Görünümü değiştir (tema: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Temsilci kişiliğini değiştir',
cmd_skills: 'Mevcut Hermes becerilerini listele',
cmd_use: 'Sonraki mesaj için bir beceriyi zorla',
available_commands: 'Mevcut komutlar:',
type_slash: 'Komutları görmek için / yazın',
conversation_cleared: 'Görüşme temizlendi',
+10
View File
@@ -568,6 +568,16 @@ async function send(){
let msgText=text;
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`;
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploadedPaths.join(', ')}]`;
if(_forcedSkillDirectivePending){
const _pending=_forcedSkillDirectivePending;
if(!_pending.sessionId||_pending.sessionId===activeSid){
const _directive = await _pending.promise;
if(_forcedSkillDirectivePending===_pending)_forcedSkillDirectivePending = null;
if(typeof _directive==='string'&&_directive){
msgText=`${_directive}\n\n${msgText||''}`.trim();
}
}
}
if(!msgText){setComposerStatus('Nothing to send');return;}
$('msg').value='';autoResize();
+1 -1
View File
@@ -6716,7 +6716,7 @@ async function loadProvidersPanel(){
try{
const data=await api('/api/providers');
const quota=await _fetchProviderQuotaStatus(false).catch(e=>({ok:false,status:'unavailable',quota:null,message:e.message||t('provider_quota_unavailable'),client_fetched_at:new Date().toISOString()}));
const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth||p.is_custom);
const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider);
list.innerHTML='';
_providerCardEls.clear();
const quotaCard=_buildProviderQuotaCard(quota);
+1 -1
View File
@@ -107,7 +107,7 @@ class TestCustomProvidersInGetProviders:
def test_providers_panel_renders_config_yaml_custom_providers(self):
"""Settings → Providers must not filter out read-only custom providers."""
src = open("static/panels.js", encoding="utf-8").read()
assert "filter(p=>p.configurable||p.is_oauth||p.is_custom)" in src
assert "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)" in src
assert "Custom provider loaded from config.yaml / hermes model" in src
assert "if(p.configurable){" in src
+10 -3
View File
@@ -290,9 +290,16 @@ class TestProviderListFilter:
"This excludes ALL OAuth providers (openai-codex, nous, copilot) from the "
"Settings → Providers panel. The filter must be 'filter(p=>p.configurable||p.is_oauth)'."
)
fixed_filter_idx = self.JS.find("filter(p=>p.configurable||p.is_oauth)")
extended_filter_idx = self.JS.find("filter(p=>p.configurable||p.is_oauth||p.is_custom)")
assert fixed_filter_idx != -1 or extended_filter_idx != -1, (
assert "p.is_oauth" in self.JS, (
"The provider filter must include p.is_oauth in panels.js. "
"OAuth providers will not appear in Settings → Providers."
)
# Current filter also includes is_custom and is_plugin_provider; require
# the full expression so partial regressions are caught.
assert (
"filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)"
in self.JS
), (
"Settings → Providers filter must keep OAuth, custom, and plugin "
"model-provider entries visible."
)
+116
View File
@@ -0,0 +1,116 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def read(path):
return (ROOT / path).read_text(encoding="utf-8")
def test_use_entry_in_commands_array():
src = read("static/commands.js")
assert "{name:'use'," in src, "COMMANDS must contain a {name:'use', ...} entry"
def test_use_entry_precedes_stop_entry():
src = read("static/commands.js")
use_pos = src.index("{name:'use',")
stop_pos = src.index("{name:'stop',")
assert use_pos < stop_pos, "/use must be registered before /stop in COMMANDS"
def test_cmdUse_function_defined():
src = read("static/commands.js")
assert "async function cmdUse(args)" in src, "cmdUse function must be defined"
def test_forced_skill_directive_declared():
src = read("static/commands.js")
assert "let _forcedSkillDirectivePending=null;" in src, "_forcedSkillDirectivePending must be declared at module scope"
def test_forced_skill_directive_set_in_cmdUse():
src = read("static/commands.js")
assert "pending.promise = new Promise" in src, "cmdUse must create a pending Promise"
assert "_forcedSkillDirectivePending = pending;" in src, "cmdUse must publish the pending directive before awaiting"
def test_use_entry_has_noEcho():
src = read("static/commands.js")
# Extract the /use entry line and check noEcho:true is present
idx = src.index("{name:'use',")
line_end = src.index("}", idx)
entry = src[idx:line_end + 1]
assert "noEcho:true" in entry, "/use entry must have noEcho:true"
def test_use_entry_has_subArgs_skills():
src = read("static/commands.js")
idx = src.index("{name:'use',")
line_end = src.index("}", idx)
entry = src[idx:line_end + 1]
assert "subArgs:'skills'" in entry, "/use entry must have subArgs:'skills' for autocomplete"
def test_directive_consumed_at_injection_site():
"""_forcedSkillDirectivePending is cleared at the consume site, not in finally."""
src = read("static/messages.js")
finally_part = src.split("finally")[1] if "finally" in src else ""
assert "_forcedSkillDirectivePending = null;" not in finally_part, \
"_forcedSkillDirectivePending must NOT be cleared in the finally block"
assert "const _directive = await _pending.promise;" in src, \
"consume site must await the pending promise"
assert "_forcedSkillDirectivePending = null;" in src, \
"_forcedSkillDirectivePending must be cleared somewhere in messages.js"
def test_directive_injection_before_empty_guard():
src = read("static/messages.js")
inject_pos = src.index("_forcedSkillDirectivePending")
guard_pos = src.index("if(!msgText){setComposerStatus('Nothing to send');return;}")
assert inject_pos < guard_pos, "directive injection must appear before the if(!msgText) guard"
def test_directive_text_uses_match_name():
src = read("static/commands.js")
assert "match.name" in src, "directive must use match.name (canonical casing), not raw user input"
assert "[USER OVERRIDE] You MUST consult skill '" in src, "directive text must match the specified format"
def test_pending_promise_set_synchronously():
"""_forcedSkillDirectivePending must be set before the first await in cmdUse."""
src = read("static/commands.js")
fn_start = src.index("async function cmdUse(args)")
fn_body = src[fn_start:]
pending_pos = fn_body.index("_forcedSkillDirectivePending = pending;")
first_await = fn_body.index("await ")
assert pending_pos < first_await, \
"_forcedSkillDirectivePending must be set before the first await to close the race window"
def test_directive_survives_local_slash_commands():
"""The consume block must appear after the slash-command early-return, not before."""
src = read("static/messages.js")
early_return = src.index("autoResize();hideCmdDropdown();return;")
consume = src.index("_forcedSkillDirectivePending")
assert early_return < consume, \
"slash-command early-return must precede the directive consume block"
def test_directive_pending_captures_session_id():
src = read("static/commands.js")
assert "const pending = {sessionId:S.session&&S.session.session_id||null,promise:null};" in src, \
"cmdUse must capture the session where /use was issued"
assert "const isCurrentSession = () => !pending.sessionId || (S.session&&S.session.session_id)===pending.sessionId;" in src, \
"async /use completion must avoid writing status messages into a different session"
def test_directive_only_consumed_by_matching_session():
src = read("static/messages.js")
assert "const _pending=_forcedSkillDirectivePending;" in src, \
"send() must snapshot the pending directive before awaiting it"
assert "if(!_pending.sessionId||_pending.sessionId===activeSid){" in src, \
"send() must only consume /use directives issued for the active session"
assert "if(_forcedSkillDirectivePending===_pending)_forcedSkillDirectivePending = null;" in src, \
"send() must not clear a newer pending directive created while awaiting"
+92
View File
@@ -1,8 +1,11 @@
import base64
import io
import json
import hashlib
from types import SimpleNamespace
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
@@ -133,6 +136,95 @@ def test_passkey_login_verifies_signature_and_updates_usage(monkeypatch, tmp_pat
assert cred["last_used_at"] is not None
def test_passkey_options_evict_oldest_challenges_per_context(monkeypatch, tmp_path):
passkeys = _set_paths(monkeypatch, tmp_path)
passkeys._save_credentials([{"id": b64u(b"credential-3"), "label": "This device"}])
monkeypatch.setattr(passkeys, "_MAX_CHALLENGES_PER_CONTEXT", 2)
first = passkeys.authentication_options(FakeHandler())
second = passkeys.authentication_options(FakeHandler())
third = passkeys.authentication_options(FakeHandler())
pending = json.loads((tmp_path / ".passkey_challenges.json").read_text(encoding="utf-8"))
assert set(pending) == {second["challenge"], third["challenge"]}
assert first["challenge"] not in pending
def test_passkey_options_evict_oldest_challenges_globally(monkeypatch, tmp_path):
passkeys = _set_paths(monkeypatch, tmp_path)
passkeys._save_credentials([{"id": b64u(b"credential-4"), "label": "This device"}])
monkeypatch.setattr(passkeys, "_MAX_CHALLENGES", 2)
monkeypatch.setattr(passkeys, "_MAX_CHALLENGES_PER_CONTEXT", 10)
first = passkeys.authentication_options(FakeHandler())
second = passkeys.authentication_options(FakeHandler())
third = passkeys.authentication_options(FakeHandler())
pending = json.loads((tmp_path / ".passkey_challenges.json").read_text(encoding="utf-8"))
assert set(pending) == {second["challenge"], third["challenge"]}
assert first["challenge"] not in pending
class RouteFakeHandler:
def __init__(self):
self.headers = FakeHeaders({"Host": "localhost:8787", "Content-Length": "0"})
self.rfile = io.BytesIO(b"")
self.wfile = io.BytesIO()
self.status = None
self.sent_headers = []
self.client_address = ("127.0.0.1", 12345)
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.sent_headers.append((key, value))
def end_headers(self):
pass
def test_passkey_options_rate_limit_errors_return_429(monkeypatch):
import api.auth as auth
import api.passkeys as passkeys
import api.routes as routes
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
def raise_rate_limit(_handler):
raise passkeys.PasskeyRateLimitError("too many")
monkeypatch.setattr(passkeys, "authentication_options", raise_rate_limit)
handler = RouteFakeHandler()
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/options"))
assert handler.status == 429
assert json.loads(handler.wfile.getvalue())["error"] == "too many"
def test_passkey_register_options_handles_base_passkey_errors(monkeypatch):
import api.auth as auth
import api.passkeys as passkeys
import api.routes as routes
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
def raise_passkey_error(_handler):
raise passkeys.PasskeyError("plain passkey error")
monkeypatch.setattr(passkeys, "registration_options", raise_passkey_error)
handler = RouteFakeHandler()
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/register/options"))
assert handler.status == 400
assert json.loads(handler.wfile.getvalue())["error"] == "plain passkey error"
def test_auth_status_reports_passkey_availability_source_contract():
src = open("api/routes.py", encoding="utf-8").read()
assert '"passkeys_enabled"' in src
+225
View File
@@ -0,0 +1,225 @@
"""Regression tests for model-provider plugin discovery in WebUI.
Plugin profiles under ``plugins/model-providers/<name>/`` are auto-registered
in the Hermes agent CLI. WebUI must expose them in Settings → Providers and
the model picker without hardcoding each slug.
"""
from __future__ import annotations
import sys
import types
from types import SimpleNamespace
import api.config as config
import api.profiles as profiles
from api.plugin_providers import invalidate_plugin_model_provider_cache
def _install_fake_yandex_plugin(monkeypatch):
profile = SimpleNamespace(
name="yandex",
display_name="Yandex AI Studio",
env_vars=("YANDEX_API_KEY", "YANDEX_FOLDER_ID"),
auth_type="api_key",
aliases=("yandex-ai-studio",),
)
def _fake_list_providers():
return [profile]
fake_providers = types.ModuleType("providers")
fake_providers.list_providers = _fake_list_providers
monkeypatch.setitem(sys.modules, "providers", fake_providers)
invalidate_plugin_model_provider_cache()
def _install_fake_hermes_cli(monkeypatch, *, authenticated: bool = True, model_ids: list[str] | None = None):
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.list_available_providers = lambda: [
{
"id": "yandex",
"label": "Yandex AI Studio",
"aliases": [],
"authenticated": authenticated,
}
]
fake_models.provider_model_ids = lambda pid: list(model_ids or []) if pid == "yandex" else []
fake_auth = types.ModuleType("hermes_cli.auth")
fake_auth.get_auth_status = lambda pid: (
{
"logged_in": True,
"configured": True,
"key_source": "YANDEX_API_KEY",
}
if pid == "yandex"
else {}
)
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
class TestPluginModelProvidersSettings:
def test_get_providers_includes_plugin_model_provider(self, monkeypatch, tmp_path):
_install_fake_yandex_plugin(monkeypatch)
_install_fake_hermes_cli(monkeypatch, model_ids=["deepseek-v4-flash/latest"])
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
env_path = tmp_path / ".env"
env_path.write_text("YANDEX_API_KEY=test-yandex-key-12345\n", encoding="utf-8")
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {"provider": "gemini"}
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
from api.providers import get_providers
try:
result = get_providers()
yandex = next((p for p in result["providers"] if p["id"] == "yandex"), None)
assert yandex is not None, "plugin model-provider must appear in Settings → Providers"
assert yandex["display_name"] == "Yandex AI Studio"
assert yandex["has_key"] is True
assert yandex["configurable"] is True
assert yandex.get("is_plugin_provider") is True
assert yandex["models_total"] >= 1
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config.invalidate_models_cache()
def test_get_providers_plugin_key_source_from_auth_store(self, monkeypatch, tmp_path):
"""Credential-pool auth must not be misreported as config_yaml."""
_install_fake_yandex_plugin(monkeypatch)
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.list_available_providers = lambda: []
fake_models.provider_model_ids = lambda pid: []
fake_auth = types.ModuleType("hermes_cli.auth")
fake_auth.get_auth_status = lambda pid: (
{
"logged_in": True,
"configured": True,
"key_source": "credential_pool:yandex",
}
if pid == "yandex"
else {}
)
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {}
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
from api.providers import get_providers
try:
result = get_providers()
yandex = next((p for p in result["providers"] if p["id"] == "yandex"), None)
assert yandex is not None
assert yandex["has_key"] is True
assert yandex["key_source"] == "credential_pool:yandex"
assert yandex["key_source"] != "config_yaml"
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config.invalidate_models_cache()
def test_set_provider_key_accepts_plugin_env_var(self, monkeypatch, tmp_path):
_install_fake_yandex_plugin(monkeypatch)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
from api.providers import set_provider_key
result = set_provider_key("yandex", "test-yandex-key-abcdef")
assert result["ok"] is True
env_text = (tmp_path / ".env").read_text(encoding="utf-8")
assert "YANDEX_API_KEY=test-yandex-key-abcdef" in env_text
class TestPluginOnlyExcludesStaticProviders:
def test_bundled_agent_profiles_are_not_plugin_only(self, monkeypatch):
"""Agent bundled profiles must not hijack WebUI static/custom paths."""
_install_fake_yandex_plugin(monkeypatch)
from api.plugin_providers import (
effective_provider_display_name,
is_plugin_model_provider,
plugin_model_provider_ids,
)
from api.config import _PROVIDER_DISPLAY
assert is_plugin_model_provider("yandex") is True
assert "yandex" in plugin_model_provider_ids()
for static_pid in ("custom", "gemini", "nous", "anthropic"):
assert is_plugin_model_provider(static_pid) is False, static_pid
assert static_pid not in plugin_model_provider_ids()
assert effective_provider_display_name("custom", _PROVIDER_DISPLAY) == "Custom"
assert effective_provider_display_name("gemini", _PROVIDER_DISPLAY) == "Gemini"
class TestPluginModelProvidersPanelFilter:
def test_providers_panel_includes_plugin_model_providers(self):
src = open("static/panels.js", encoding="utf-8").read()
assert "p.is_plugin_provider" in src
assert "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)" in src
class TestPluginModelProvidersPicker:
def test_model_picker_includes_authenticated_plugin_provider(self, monkeypatch, tmp_path):
_install_fake_yandex_plugin(monkeypatch)
_install_fake_hermes_cli(
monkeypatch,
authenticated=True,
model_ids=["gpt://folder/deepseek-v4-flash/latest"],
)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {"provider": "gemini", "default": "gemini-2.5-flash"}
config.cfg["providers"] = {}
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
config.invalidate_models_cache()
try:
models = config.get_available_models()
yandex_group = next(
(g for g in models.get("groups", []) if g.get("provider_id") == "yandex"),
None,
)
assert yandex_group is not None, "authenticated plugin provider must appear in picker"
assert yandex_group["provider"] == "Yandex AI Studio"
assert len(yandex_group.get("models") or []) >= 1
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config.invalidate_models_cache()