* 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:
15 files changed
+799
-33
No files matched your search
@@ -3,6 +3,15 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
## [v0.51.269] — 2026-06-05 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+19
-3
@@ -27,6 +27,10 @@ from urllib.parse import parse_qs, urlparse
|
|||||||
|
|
||||||
# ── Basic layout ──────────────────────────────────────────────────────────────
|
# ── Basic layout ──────────────────────────────────────────────────────────────
|
||||||
import api.paths as _paths
|
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
|
HOME = _paths.HOME
|
||||||
_hermes_home_has_webui_state = _paths._hermes_home_has_webui_state
|
_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.
|
# 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.
|
# Disk delete is outside the lock — file I/O shouldn't block other readers.
|
||||||
_delete_models_cache_on_disk()
|
_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):
|
def invalidate_credential_pool_cache(provider_id: str):
|
||||||
@@ -3787,7 +3797,9 @@ def get_available_models() -> dict:
|
|||||||
# aliases; ``isinstance(_provider_cfg, dict)`` accepts custom
|
# aliases; ``isinstance(_provider_cfg, dict)`` accepts custom
|
||||||
# entries that supply their own models/api_key/base_url. (#2399)
|
# entries that supply their own models/api_key/base_url. (#2399)
|
||||||
_is_known_provider = (
|
_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)
|
_is_provider_config = isinstance(_provider_cfg, dict)
|
||||||
if not (_is_known_provider or _is_provider_config):
|
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]
|
group["models_endpoint_error"] = _named_custom_errors[pid]
|
||||||
groups.append(group)
|
groups.append(group)
|
||||||
continue
|
continue
|
||||||
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
|
provider_name = _effective_provider_display_name(pid, _PROVIDER_DISPLAY)
|
||||||
if pid == "openrouter":
|
if pid == "openrouter":
|
||||||
# OpenRouter has two model surfaces:
|
# OpenRouter has two model surfaces:
|
||||||
# (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models()
|
# (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models()
|
||||||
@@ -4545,7 +4557,11 @@ def get_available_models() -> dict:
|
|||||||
"models": models,
|
"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
|
# Look up provider_cfg using the original raw key from
|
||||||
# config.yaml so that mixed-case / underscore keys like
|
# config.yaml so that mixed-case / underscore keys like
|
||||||
# ``CLIPpoxy`` or ``snake_case_provider`` still resolve
|
# ``CLIPpoxy`` or ``snake_case_provider`` still resolve
|
||||||
|
|||||||
+47
-7
@@ -12,6 +12,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -29,7 +30,10 @@ except Exception: # pragma: no cover - exercised by source tests instead
|
|||||||
|
|
||||||
_CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
|
_CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
|
||||||
_CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.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"
|
_RP_NAME = "Hermes WebUI"
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +41,10 @@ class PasskeyError(ValueError):
|
|||||||
"""Raised for user-correctable WebAuthn failures."""
|
"""Raised for user-correctable WebAuthn failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRateLimitError(PasskeyError):
|
||||||
|
"""Raised when too many outstanding WebAuthn challenges are pending."""
|
||||||
|
|
||||||
|
|
||||||
def _b64u(data: bytes) -> str:
|
def _b64u(data: bytes) -> str:
|
||||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
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]]:
|
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, {})
|
raw = _json_load(_CHALLENGES_FILE, {})
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return {}
|
return {}
|
||||||
@@ -117,16 +127,46 @@ def _load_challenges() -> dict[str, dict[str, Any]]:
|
|||||||
return clean
|
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:
|
def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
|
||||||
data = _load_challenges()
|
with _CHALLENGES_LOCK:
|
||||||
data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
|
data = _load_challenges()
|
||||||
_atomic_write_json(_CHALLENGES_FILE, data)
|
_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]:
|
def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
|
||||||
data = _load_challenges()
|
with _CHALLENGES_LOCK:
|
||||||
entry = data.pop(challenge, None)
|
data = _load_challenges()
|
||||||
_atomic_write_json(_CHALLENGES_FILE, data)
|
entry = data.pop(challenge, None)
|
||||||
|
_atomic_write_json(_CHALLENGES_FILE, data)
|
||||||
if not entry or entry.get("kind") != kind:
|
if not entry or entry.get("kind") != kind:
|
||||||
raise PasskeyError("Passkey challenge expired. Try again.")
|
raise PasskeyError("Passkey challenge expired. Try again.")
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -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
@@ -43,10 +43,21 @@ from api.config import (
|
|||||||
invalidate_models_cache,
|
invalidate_models_cache,
|
||||||
reload_config,
|
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__)
|
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:
|
def _custom_provider_name_matches(provider_id: str, name: object) -> bool:
|
||||||
"""Return True when *provider_id* refers to a named custom provider."""
|
"""Return True when *provider_id* refers to a named custom provider."""
|
||||||
pid = str(provider_id or "").strip().lower()
|
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":
|
if (provider_id or "").strip().lower() != "openai":
|
||||||
return False
|
return False
|
||||||
values: list[object] = []
|
values: list[object] = []
|
||||||
env_var = _PROVIDER_ENV_VAR.get(provider_id)
|
env_var = _provider_env_var_for(provider_id)
|
||||||
if env_var:
|
if env_var:
|
||||||
env_path = _get_hermes_home() / ".env"
|
env_path = _get_hermes_home() / ".env"
|
||||||
env_values = _load_env_file(env_path)
|
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``
|
4. ``config.yaml → providers.<id>.api_key``
|
||||||
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
|
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:
|
if env_var:
|
||||||
env_path = _get_hermes_home() / ".env"
|
env_path = _get_hermes_home() / ".env"
|
||||||
env_values = _load_env_file(env_path)
|
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:
|
def _get_provider_api_key(provider_id: str) -> str | None:
|
||||||
"""Return a configured provider API key without exposing it to callers."""
|
"""Return a configured provider API key without exposing it to callers."""
|
||||||
provider_id = (provider_id or "").strip().lower()
|
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:
|
if env_var:
|
||||||
env_path = _get_hermes_home() / ".env"
|
env_path = _get_hermes_home() / ".env"
|
||||||
env_values = _load_env_file(env_path)
|
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:
|
if value:
|
||||||
env[key] = 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:
|
if env_var and api_key:
|
||||||
env[env_var] = 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
|
# Collect all known provider IDs from multiple sources
|
||||||
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
|
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
|
# Also detect providers from config.yaml providers section
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
@@ -1763,9 +1775,21 @@ def get_providers() -> dict[str, Any]:
|
|||||||
known_ids.update(_OAUTH_PROVIDERS)
|
known_ids.update(_OAUTH_PROVIDERS)
|
||||||
|
|
||||||
for pid in sorted(known_ids):
|
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)
|
is_oauth = _provider_is_oauth(pid)
|
||||||
has_key = _provider_has_key(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
|
# Determine key source
|
||||||
key_source = "none"
|
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)
|
logger.debug("hermes_cli auth check failed for %s", pid, exc_info=True)
|
||||||
# keep has_key from _provider_has_key()
|
# keep has_key from _provider_has_key()
|
||||||
elif has_key:
|
elif has_key:
|
||||||
env_var = _PROVIDER_ENV_VAR.get(pid)
|
env_var = _provider_env_var_for(pid)
|
||||||
if env_var:
|
if env_var:
|
||||||
env_path = _get_hermes_home() / ".env"
|
env_path = _get_hermes_home() / ".env"
|
||||||
env_values = _load_env_file(env_path)
|
env_values = _load_env_file(env_path)
|
||||||
@@ -1821,19 +1845,29 @@ def get_providers() -> dict[str, Any]:
|
|||||||
aliased = True
|
aliased = True
|
||||||
break
|
break
|
||||||
if not aliased:
|
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:
|
else:
|
||||||
key_source = "config_yaml"
|
_plugin_ks = (
|
||||||
elif pid not in _PROVIDER_ENV_VAR:
|
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
|
# Fallback: provider is not a known API-key provider and not in
|
||||||
# the hardcoded _OAUTH_PROVIDERS set. It may be a custom or
|
# the hardcoded _OAUTH_PROVIDERS set. It may be a custom or
|
||||||
# newly-added OAuth provider (e.g. Anthropic connected via OAuth).
|
# newly-added OAuth provider (e.g. Anthropic connected via OAuth).
|
||||||
# Check live auth status so the Providers tab agrees with the
|
# Check live auth status so the Providers tab agrees with the
|
||||||
# model picker (#1212).
|
# model picker (#1212).
|
||||||
#
|
#
|
||||||
# IMPORTANT: we skip providers in _PROVIDER_ENV_VAR because they
|
# IMPORTANT: we skip providers with a known API-key env var because
|
||||||
# are pure API-key providers — calling get_auth_status() for every
|
# they are pure API-key providers — calling get_auth_status() for
|
||||||
# unconfigured API-key provider would add unnecessary latency
|
# every unconfigured API-key provider would add unnecessary latency
|
||||||
# (network round-trip per provider) on the Settings page.
|
# (network round-trip per provider) on the Settings page.
|
||||||
# Validate pid looks like a real provider before probing
|
# Validate pid looks like a real provider before probing
|
||||||
import re as _re
|
import re as _re
|
||||||
@@ -1924,6 +1958,21 @@ def get_providers() -> dict[str, Any]:
|
|||||||
models_total = len(models)
|
models_total = len(models)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to load LM Studio models from hermes_cli")
|
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
|
# Also include models from config.yaml providers section
|
||||||
if isinstance(providers_cfg, dict):
|
if isinstance(providers_cfg, dict):
|
||||||
provider_cfg = providers_cfg.get(pid, {})
|
provider_cfg = providers_cfg.get(pid, {})
|
||||||
@@ -1941,11 +1990,13 @@ def get_providers() -> dict[str, Any]:
|
|||||||
if pid != "nous":
|
if pid != "nous":
|
||||||
models_total = len(models)
|
models_total = len(models)
|
||||||
|
|
||||||
|
_is_plugin = is_plugin_model_provider(pid)
|
||||||
providers.append({
|
providers.append({
|
||||||
"id": pid,
|
"id": pid,
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"has_key": has_key,
|
"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,
|
"is_oauth": is_oauth,
|
||||||
"key_source": key_source,
|
"key_source": key_source,
|
||||||
"auth_error": auth_error,
|
"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.",
|
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:
|
if not env_var:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"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.",
|
f"This provider does not have a known env var mapping.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -8024,7 +8024,7 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
|
|
||||||
if parsed.path == "/api/auth/passkey/options":
|
if parsed.path == "/api/auth/passkey/options":
|
||||||
from api.auth import _passkey_feature_flag_enabled, is_auth_enabled
|
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():
|
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)
|
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)
|
return j(handler, {"error": "Auth not enabled"}, status=400)
|
||||||
try:
|
try:
|
||||||
return j(handler, {"ok": True, "publicKey": authentication_options(handler)})
|
return j(handler, {"ok": True, "publicKey": authentication_options(handler)})
|
||||||
|
except PasskeyRateLimitError as e:
|
||||||
|
return bad(handler, str(e), status=429)
|
||||||
except PasskeyError as e:
|
except PasskeyError as e:
|
||||||
return bad(handler, str(e), status=400)
|
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":
|
if parsed.path == "/api/auth/passkey/register/options":
|
||||||
from api.auth import _passkey_feature_flag_enabled
|
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():
|
if not _passkey_feature_flag_enabled():
|
||||||
return j(handler, {"error": "Passkey support is disabled."}, status=404)
|
return j(handler, {"error": "Passkey support is disabled."}, status=404)
|
||||||
return j(handler, {"ok": True, "publicKey": registration_options(handler)})
|
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":
|
if parsed.path == "/api/auth/passkey/register":
|
||||||
from api.auth import _passkey_feature_flag_enabled
|
from api.auth import _passkey_feature_flag_enabled
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const COMMANDS=[
|
|||||||
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true},
|
{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:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
|
||||||
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
|
{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:'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:'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},
|
{name:'queue', desc:t('cmd_queue'), fn:cmdQueue, arg:'message', noEcho:true},
|
||||||
@@ -94,6 +95,7 @@ function getMatchingCommands(prefix){
|
|||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _forcedSkillDirectivePending=null;
|
||||||
let _slashModelCache=null;
|
let _slashModelCache=null;
|
||||||
let _slashModelCachePromise=null;
|
let _slashModelCachePromise=null;
|
||||||
let _slashPersonalityCache=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){
|
async function cmdPersonality(args){
|
||||||
if(!S.session){showToast(t('no_active_session'));return;}
|
if(!S.session){showToast(t('no_active_session'));return;}
|
||||||
if(!args){
|
if(!args){
|
||||||
|
|||||||
@@ -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_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_personality: 'Switch agent personality',
|
||||||
cmd_skills: 'List available Hermes skills',
|
cmd_skills: 'List available Hermes skills',
|
||||||
|
cmd_use: 'Force a skill for the next message',
|
||||||
available_commands: 'Available commands:',
|
available_commands: 'Available commands:',
|
||||||
type_slash: 'Type / to see commands',
|
type_slash: 'Type / to see commands',
|
||||||
conversation_cleared: 'Conversation cleared',
|
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_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_personality: "Cambia personalità dell'agente",
|
||||||
cmd_skills: 'Elenca le skill Hermes disponibili',
|
cmd_skills: 'Elenca le skill Hermes disponibili',
|
||||||
|
cmd_use: 'Forza una skill per il prossimo messaggio',
|
||||||
available_commands: 'Comandi disponibili:',
|
available_commands: 'Comandi disponibili:',
|
||||||
type_slash: 'Digita / per vedere i comandi',
|
type_slash: 'Digita / per vedere i comandi',
|
||||||
conversation_cleared: 'Conversazione cancellata',
|
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_theme: '外観を切り替え (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
|
||||||
cmd_personality: 'エージェントのパーソナリティを切り替え',
|
cmd_personality: 'エージェントのパーソナリティを切り替え',
|
||||||
cmd_skills: '利用可能な Hermes スキルを一覧表示',
|
cmd_skills: '利用可能な Hermes スキルを一覧表示',
|
||||||
|
cmd_use: '次のメッセージにスキルを強制適用',
|
||||||
available_commands: '利用可能なコマンド:',
|
available_commands: '利用可能なコマンド:',
|
||||||
type_slash: '/ を入力するとコマンド一覧',
|
type_slash: '/ を入力するとコマンド一覧',
|
||||||
conversation_cleared: '会話をクリアしました',
|
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_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
|
||||||
cmd_personality: 'Переключить личность агента',
|
cmd_personality: 'Переключить личность агента',
|
||||||
cmd_skills: 'Показать доступные навыки Hermes',
|
cmd_skills: 'Показать доступные навыки Hermes',
|
||||||
|
cmd_use: 'Принудительно применить навык к следующему сообщению',
|
||||||
available_commands: 'Доступные команды:',
|
available_commands: 'Доступные команды:',
|
||||||
type_slash: 'Введите /, чтобы увидеть команды',
|
type_slash: 'Введите /, чтобы увидеть команды',
|
||||||
conversation_cleared: 'Беседа очищена',
|
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_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_personality: 'Cambiar la personalidad del agente',
|
||||||
cmd_skills: 'Listar las skills de Hermes disponibles',
|
cmd_skills: 'Listar las skills de Hermes disponibles',
|
||||||
|
cmd_use: 'Forzar una skill para el próximo mensaje',
|
||||||
available_commands: 'Comandos disponibles:',
|
available_commands: 'Comandos disponibles:',
|
||||||
type_slash: 'Escribe / para ver los comandos',
|
type_slash: 'Escribe / para ver los comandos',
|
||||||
conversation_cleared: 'Conversación borrada',
|
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_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_personality: 'Agenten-Persönlichkeit wechseln',
|
||||||
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
|
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
|
||||||
|
cmd_use: 'Einen Skill für die nächste Nachricht erzwingen',
|
||||||
available_commands: 'Verfügbare Befehle:',
|
available_commands: 'Verfügbare Befehle:',
|
||||||
type_slash: 'Tippe / für Befehle',
|
type_slash: 'Tippe / für Befehle',
|
||||||
conversation_cleared: 'Konversation gelöscht',
|
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_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
|
||||||
cmd_personality: '切换 Agent 人设',
|
cmd_personality: '切换 Agent 人设',
|
||||||
cmd_skills: '列出可用的 Hermes 技能',
|
cmd_skills: '列出可用的 Hermes 技能',
|
||||||
|
cmd_use: '为下一条消息强制使用技能',
|
||||||
available_commands: '可用命令:',
|
available_commands: '可用命令:',
|
||||||
type_slash: '输入 / 可查看命令',
|
type_slash: '输入 / 可查看命令',
|
||||||
conversation_cleared: '对话已清空',
|
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_theme: '切換外觀(主題:system/dark/light,skin:default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
|
||||||
cmd_personality: '切換 Agent 角色',
|
cmd_personality: '切換 Agent 角色',
|
||||||
cmd_skills: '列出可用的 Hermes 技能',
|
cmd_skills: '列出可用的 Hermes 技能',
|
||||||
|
cmd_use: '為下一則訊息強制使用技能',
|
||||||
available_commands: '可用命令:',
|
available_commands: '可用命令:',
|
||||||
type_slash: '輸入 / 檢視命令',
|
type_slash: '輸入 / 檢視命令',
|
||||||
conversation_cleared: '已清除對話',
|
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_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_personality: 'Trocar personalidade do agente',
|
||||||
cmd_skills: 'Listar skills disponíveis do Hermes',
|
cmd_skills: 'Listar skills disponíveis do Hermes',
|
||||||
|
cmd_use: 'Forçar uma skill para a próxima mensagem',
|
||||||
available_commands: 'Comandos disponíveis:',
|
available_commands: 'Comandos disponíveis:',
|
||||||
type_slash: 'Digite / para ver comandos',
|
type_slash: 'Digite / para ver comandos',
|
||||||
conversation_cleared: 'Conversa limpa',
|
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_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_personality: 'Switch agent personality',
|
||||||
cmd_skills: 'List available Hermes skills',
|
cmd_skills: 'List available Hermes skills',
|
||||||
|
cmd_use: 'Force a skill for the next message',
|
||||||
available_commands: '사용 가능한 명령:',
|
available_commands: '사용 가능한 명령:',
|
||||||
type_slash: '/ 를 입력해 명령 보기',
|
type_slash: '/ 를 입력해 명령 보기',
|
||||||
conversation_cleared: '대화를 지웠습니다',
|
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_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_personality: 'Personnalité de l\'agent de commutation',
|
||||||
cmd_skills: 'Lister les compétences Hermès disponibles',
|
cmd_skills: 'Lister les compétences Hermès disponibles',
|
||||||
|
cmd_use: 'Forcer une compétence pour le prochain message',
|
||||||
available_commands: 'Commandes disponibles :',
|
available_commands: 'Commandes disponibles :',
|
||||||
type_slash: 'Tapez / pour voir les commandes',
|
type_slash: 'Tapez / pour voir les commandes',
|
||||||
conversation_cleared: 'Conversation effacée',
|
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_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_personality: 'Temsilci kişiliğini değiştir',
|
||||||
cmd_skills: 'Mevcut Hermes becerilerini listele',
|
cmd_skills: 'Mevcut Hermes becerilerini listele',
|
||||||
|
cmd_use: 'Sonraki mesaj için bir beceriyi zorla',
|
||||||
available_commands: 'Mevcut komutlar:',
|
available_commands: 'Mevcut komutlar:',
|
||||||
type_slash: 'Komutları görmek için / yazın',
|
type_slash: 'Komutları görmek için / yazın',
|
||||||
conversation_cleared: 'Görüşme temizlendi',
|
conversation_cleared: 'Görüşme temizlendi',
|
||||||
|
|||||||
@@ -568,6 +568,16 @@ async function send(){
|
|||||||
let msgText=text;
|
let msgText=text;
|
||||||
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`;
|
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(', ')}]`;
|
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;}
|
if(!msgText){setComposerStatus('Nothing to send');return;}
|
||||||
|
|
||||||
$('msg').value='';autoResize();
|
$('msg').value='';autoResize();
|
||||||
|
|||||||
+1
-1
@@ -6716,7 +6716,7 @@ async function loadProvidersPanel(){
|
|||||||
try{
|
try{
|
||||||
const data=await api('/api/providers');
|
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 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='';
|
list.innerHTML='';
|
||||||
_providerCardEls.clear();
|
_providerCardEls.clear();
|
||||||
const quotaCard=_buildProviderQuotaCard(quota);
|
const quotaCard=_buildProviderQuotaCard(quota);
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class TestCustomProvidersInGetProviders:
|
|||||||
def test_providers_panel_renders_config_yaml_custom_providers(self):
|
def test_providers_panel_renders_config_yaml_custom_providers(self):
|
||||||
"""Settings → Providers must not filter out read-only custom providers."""
|
"""Settings → Providers must not filter out read-only custom providers."""
|
||||||
src = open("static/panels.js", encoding="utf-8").read()
|
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 "Custom provider loaded from config.yaml / hermes model" in src
|
||||||
assert "if(p.configurable){" in src
|
assert "if(p.configurable){" in src
|
||||||
|
|
||||||
|
|||||||
@@ -290,9 +290,16 @@ class TestProviderListFilter:
|
|||||||
"This excludes ALL OAuth providers (openai-codex, nous, copilot) from the "
|
"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)'."
|
"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)")
|
assert "p.is_oauth" in self.JS, (
|
||||||
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, (
|
|
||||||
"The provider filter must include p.is_oauth in panels.js. "
|
"The provider filter must include p.is_oauth in panels.js. "
|
||||||
"OAuth providers will not appear in Settings → Providers."
|
"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."
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
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
|
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():
|
def test_auth_status_reports_passkey_availability_source_contract():
|
||||||
src = open("api/routes.py", encoding="utf-8").read()
|
src = open("api/routes.py", encoding="utf-8").read()
|
||||||
assert '"passkeys_enabled"' in src
|
assert '"passkeys_enabled"' in src
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user