mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Merge pull request #2757 from nesquena/release/stage-403
Release CH: v0.51.110 (stage-403 — 2-PR batch — default personality from config + sort configured providers to top)
This commit is contained in:
@@ -3,6 +3,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.110] — 2026-05-22 — Release CH (stage-403 — 2-PR batch — default personality from config + sort configured providers to top)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2747** by @s010mn — `new_session()` now reads `display.personality` from `config.yaml` as the default for new conversations. Previously every new session started with `personality=None` and required an explicit `/personality <name>` slash command. Values `'none'`, `'default'`, `'neutral'`, and empty string are treated as no-personality. Case-insensitive — `personality: Taleb` normalizes to `taleb`. Config-read is wrapped in try/except so malformed config falls back to the prior behavior rather than crashing session creation. The `/personality` slash command still works for per-session overrides.
|
||||
- **PR #2683** by @jasonjcwu — Sort providers so configured/custom entries appear first in both the model picker dropdown (`api/config.py::get_available_models`) and the Settings providers panel (`api/providers.py::get_providers`). Priority order: (1) the active provider, (2) `custom:*` providers from `custom_providers` config, (3) providers with configured API keys (credential pool or `config.yaml`), (4) all others alphabetical. Eliminates scrolling past 25+ unconfigured providers to find the one in active use.
|
||||
|
||||
## [v0.51.109] — 2026-05-22 — Release CG (stage-402 — 2-PR batch — sidebar action menu click stability + chat panel sidebar resync after navigation)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -4003,6 +4003,36 @@ def get_available_models() -> dict:
|
||||
or (g.get("provider_id") or "").startswith("custom:")
|
||||
]
|
||||
|
||||
# Sort groups: active provider first, then custom:* providers,
|
||||
# then providers with configured keys, then the rest alphabetically.
|
||||
_providers_with_keys: set[str] = set()
|
||||
try:
|
||||
_pool = auth_store.get("credential_pool", {}) if isinstance(auth_store, dict) else {}
|
||||
if isinstance(_pool, dict):
|
||||
for _pid in _pool:
|
||||
_providers_with_keys.add(_resolve_provider_alias(str(_pid)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_cfg_providers = cfg.get("providers", {})
|
||||
if isinstance(_cfg_providers, dict):
|
||||
for _pk, _pv in _cfg_providers.items():
|
||||
if isinstance(_pv, dict) and (_pv.get("api_key") or _pv.get("key_env")):
|
||||
_providers_with_keys.add(_resolve_provider_alias(str(_pk)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _group_sort_key(g):
|
||||
pid = g.get("provider_id") or ""
|
||||
if pid == active_provider:
|
||||
return (0, pid)
|
||||
if pid.startswith("custom:"):
|
||||
return (1, pid)
|
||||
if pid in _providers_with_keys:
|
||||
return (2, pid)
|
||||
return (3, pid)
|
||||
groups.sort(key=_group_sort_key)
|
||||
|
||||
# 12. Include model aliases so the WebUI frontend can resolve them.
|
||||
model_aliases: dict[str, str] = {}
|
||||
try:
|
||||
|
||||
@@ -1848,6 +1848,19 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
effective_model, effective_model_provider = _profile_default_model_state(profile)
|
||||
if model_provider:
|
||||
effective_model_provider = model_provider
|
||||
|
||||
# Read default personality from config display.personality
|
||||
_default_personality = None
|
||||
try:
|
||||
from api.config import get_config as _get_cfg_for_personality
|
||||
_cfg_personality = (_get_cfg_for_personality().get('display') or {}).get('personality')
|
||||
if _cfg_personality and isinstance(_cfg_personality, str):
|
||||
_cfg_personality = _cfg_personality.strip().lower()
|
||||
if _cfg_personality and _cfg_personality not in ('default', 'none', 'neutral'):
|
||||
_default_personality = _cfg_personality
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
wt = worktree_info if isinstance(worktree_info, dict) else None
|
||||
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
|
||||
s = Session(
|
||||
@@ -1856,6 +1869,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
model_provider=effective_model_provider,
|
||||
profile=profile,
|
||||
project_id=project_id,
|
||||
personality=_default_personality,
|
||||
worktree_path=wt.get('path') if wt else None,
|
||||
worktree_branch=wt.get('branch') if wt else None,
|
||||
worktree_repo_root=wt.get('repo_root') if wt else None,
|
||||
|
||||
@@ -1996,6 +1996,18 @@ def get_providers() -> dict[str, Any]:
|
||||
if isinstance(model_cfg, dict):
|
||||
active_provider = model_cfg.get("provider")
|
||||
|
||||
# Sort providers: active first, then custom:*, then has_key, then rest.
|
||||
def _provider_sort_key(p):
|
||||
pid = p.get("id") or ""
|
||||
if pid == active_provider:
|
||||
return (0, pid)
|
||||
if pid.startswith("custom:"):
|
||||
return (1, pid)
|
||||
if p.get("has_key"):
|
||||
return (2, pid)
|
||||
return (3, pid)
|
||||
providers.sort(key=_provider_sort_key)
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
"active_provider": active_provider,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Test that new_session() reads display.personality from config and uses it as default.
|
||||
|
||||
Regression test for the feature that makes /personality taleb sticky across
|
||||
new sessions — when display.personality is set in config.yaml, every new
|
||||
session should inherit it without requiring an explicit /personality command.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R1: new_session() inherits display.personality from config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_session_reads_default_personality_from_config():
|
||||
"""When display.personality is set to 'taleb', new_session() should
|
||||
create a Session with personality='taleb'."""
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
_cfg = {
|
||||
"display": {"personality": "taleb"},
|
||||
"agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb", "tone": "blunt"}}},
|
||||
}
|
||||
|
||||
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality")
|
||||
|
||||
assert s.personality == "taleb", (
|
||||
f"Expected personality='taleb', got {s.personality!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R2: 'none', 'default', 'neutral' are treated as no personality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("personality_value", ["none", "default", "neutral", ""])
|
||||
def test_new_session_ignores_neutral_personality_values(personality_value):
|
||||
"""Values like 'none', 'default', 'neutral', and '' should NOT be set as
|
||||
the session personality — they mean 'no personality overlay'."""
|
||||
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
_cfg = {
|
||||
"display": {"personality": personality_value},
|
||||
"agent": {"personalities": {}},
|
||||
}
|
||||
|
||||
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality-neutral")
|
||||
|
||||
assert s.personality is None, (
|
||||
f"Expected None for display.personality={personality_value!r}, "
|
||||
f"got {s.personality!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3: Missing display.personality → personality=None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_session_no_personality_when_config_missing():
|
||||
"""When config has no display.personality (or display section is absent),
|
||||
new_session() should set personality=None."""
|
||||
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
_cfg = {"agent": {"personalities": {}}} # No display section at all
|
||||
|
||||
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality-missing")
|
||||
|
||||
assert s.personality is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R4: Config exception is handled gracefully → personality=None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_session_handles_config_exception_gracefully():
|
||||
"""If get_config() raises, we should still get a valid session with
|
||||
personality=None (the try/except should swallow the error)."""
|
||||
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("config exploded")
|
||||
|
||||
with patch.object(cfg_mod, "get_config", side_effect=_boom), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality-boom")
|
||||
|
||||
assert s.personality is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R5: display.personality is case-insensitive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_session_personality_is_case_insensitive():
|
||||
"""display.personality='Taleb' should be normalized to 'taleb'."""
|
||||
|
||||
import api.models as m
|
||||
import api.config as cfg_mod
|
||||
|
||||
_cfg = {
|
||||
"display": {"personality": "Taleb"},
|
||||
"agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb"}}},
|
||||
}
|
||||
|
||||
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
|
||||
patch.object(m.Session, "save", return_value=None):
|
||||
s = m.new_session(workspace="/tmp/test-personality-case")
|
||||
|
||||
assert s.personality == "taleb"
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Tests for provider/model-picker sort ordering.
|
||||
|
||||
Feature: configured and custom providers float to the top in both
|
||||
the Settings provider list and the model picker dropdown.
|
||||
|
||||
Sort tiers (both endpoints):
|
||||
0 — active provider
|
||||
1 — custom:* providers
|
||||
2 — providers with a configured key (credential pool or config.yaml api_key)
|
||||
3 — everyone else, alphabetically by provider id
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import api.config as config
|
||||
import api.providers as providers_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — lightweight stubs so we don't need a real hermes-agent install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_fake_hermes_cli(monkeypatch):
|
||||
"""Stub hermes_cli so detection is deterministic in tests."""
|
||||
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: {}
|
||||
|
||||
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.delitem(sys.modules, "agent.credential_pool", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "agent", raising=False)
|
||||
|
||||
config.invalidate_models_cache()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_cache():
|
||||
"""Invalidate the TTL model cache around each test."""
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _model_list(*ids):
|
||||
"""Helper: build [{"id": x, "label": x}, ...] for _PROVIDER_MODELS."""
|
||||
return [{"id": m, "label": m} for m in ids]
|
||||
|
||||
|
||||
def _setup_config(tmp_path, monkeypatch, yaml_text, provider_models=None):
|
||||
"""Write config.yaml, point config module at it, install stubs."""
|
||||
_install_fake_hermes_cli(monkeypatch)
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(yaml_text, encoding="utf-8")
|
||||
monkeypatch.setattr(config, "_get_config_path", lambda: cfgfile)
|
||||
|
||||
# Point auth store at a non-existent file so it stays empty
|
||||
auth_path = tmp_path / "auth.json"
|
||||
monkeypatch.setattr(config, "_get_auth_store_path", lambda: auth_path)
|
||||
|
||||
# Inject provider models if given
|
||||
if provider_models:
|
||||
for pid, models in provider_models.items():
|
||||
monkeypatch.setitem(config._PROVIDER_MODELS, pid, models)
|
||||
|
||||
config.reload_config()
|
||||
|
||||
|
||||
def _teardown_config():
|
||||
config.reload_config()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# get_providers() sort order (api/providers.py)
|
||||
# ===================================================================
|
||||
|
||||
class TestProviderSortOrder:
|
||||
"""providers returned by get_providers() should respect tier ordering."""
|
||||
|
||||
def test_active_provider_comes_first(self, tmp_path, monkeypatch):
|
||||
"""The active provider (from config model.provider) is tier-0."""
|
||||
_setup_config(tmp_path, monkeypatch,
|
||||
"model:\n provider: openrouter\n default: test-model\n"
|
||||
"providers:\n"
|
||||
" anthropic:\n api_key: sk-test-123\n"
|
||||
" openai: {}\n"
|
||||
" openrouter:\n api_key: sk-or-123\n"
|
||||
)
|
||||
|
||||
result = providers_mod.get_providers()
|
||||
prov_ids = [p["id"] for p in result["providers"]]
|
||||
|
||||
assert prov_ids[0] == "openrouter", (
|
||||
f"Expected openrouter first (active), got order: {prov_ids}"
|
||||
)
|
||||
_teardown_config()
|
||||
|
||||
def test_custom_provider_before_plain(self, tmp_path, monkeypatch):
|
||||
"""custom:* providers (tier-1) sort before providers with keys (tier-2)."""
|
||||
_setup_config(tmp_path, monkeypatch,
|
||||
"model:\n provider: openai\n default: gpt-4\n"
|
||||
"providers:\n"
|
||||
" openai: {}\n"
|
||||
" anthropic:\n api_key: sk-ant-123\n"
|
||||
"custom_providers:\n"
|
||||
" - name: MyLocal\n"
|
||||
" base_url: http://localhost:8080/v1\n"
|
||||
" api_key: local-key\n"
|
||||
" models:\n"
|
||||
" - local-model-a\n"
|
||||
)
|
||||
|
||||
result = providers_mod.get_providers()
|
||||
prov_ids = [p["id"] for p in result["providers"]]
|
||||
|
||||
openai_idx = prov_ids.index("openai")
|
||||
custom_idx = prov_ids.index("custom:mylocal")
|
||||
anthropic_idx = prov_ids.index("anthropic")
|
||||
|
||||
assert openai_idx < custom_idx < anthropic_idx, (
|
||||
f"Expected openai < custom:mylocal < anthropic, got {prov_ids}"
|
||||
)
|
||||
_teardown_config()
|
||||
|
||||
def test_has_key_provider_before_no_key(self, tmp_path, monkeypatch):
|
||||
"""Providers with keys (tier-2) come before those without (tier-3)."""
|
||||
_setup_config(tmp_path, monkeypatch,
|
||||
"model:\n provider: openai\n default: gpt-4\n"
|
||||
"providers:\n"
|
||||
" openai: {}\n"
|
||||
" deepseek:\n api_key: sk-ds-123\n"
|
||||
" google: {}\n"
|
||||
" groq: {}\n"
|
||||
)
|
||||
|
||||
result = providers_mod.get_providers()
|
||||
prov_ids = [p["id"] for p in result["providers"]]
|
||||
|
||||
# deepseek has api_key in config → should be tier-2 (before tier-3)
|
||||
deepseek_idx = prov_ids.index("deepseek")
|
||||
google_idx = prov_ids.index("google")
|
||||
groq_idx = prov_ids.index("groq")
|
||||
|
||||
assert deepseek_idx < google_idx, (
|
||||
f"deepseek(has_key) at {deepseek_idx} should be before google at {google_idx}"
|
||||
)
|
||||
assert deepseek_idx < groq_idx, (
|
||||
f"deepseek(has_key) at {deepseek_idx} should be before groq at {groq_idx}"
|
||||
)
|
||||
_teardown_config()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# get_available_models() sort order (api/config.py)
|
||||
# ===================================================================
|
||||
|
||||
class TestModelPickerSortOrder:
|
||||
"""Model picker groups should follow the same tier ordering."""
|
||||
|
||||
def test_active_provider_group_is_first(self, tmp_path, monkeypatch):
|
||||
"""The group for the active provider appears first in groups list."""
|
||||
_setup_config(tmp_path, monkeypatch,
|
||||
"model:\n provider: anthropic\n default: claude-3-5-sonnet\n"
|
||||
"providers:\n"
|
||||
" openai:\n api_key: sk-oai-123\n"
|
||||
" anthropic: {}\n",
|
||||
provider_models={
|
||||
"openai": _model_list("gpt-4", "gpt-4o"),
|
||||
"anthropic": _model_list("claude-3-5-sonnet"),
|
||||
},
|
||||
)
|
||||
|
||||
result = config.get_available_models()
|
||||
group_ids = [g.get("provider_id") for g in result.get("groups", [])]
|
||||
|
||||
assert group_ids[0] == "anthropic", (
|
||||
f"Expected anthropic first (active), got: {group_ids}"
|
||||
)
|
||||
_teardown_config()
|
||||
|
||||
def test_custom_groups_before_configured(self, tmp_path, monkeypatch):
|
||||
"""custom:* groups sort before providers that merely have keys."""
|
||||
_setup_config(tmp_path, monkeypatch,
|
||||
"model:\n provider: openai\n default: gpt-4\n"
|
||||
"providers:\n"
|
||||
" openai:\n api_key: sk-oai-123\n"
|
||||
"custom_providers:\n"
|
||||
" - name: MyLocal\n"
|
||||
" base_url: http://localhost:8080/v1\n"
|
||||
" api_key: local-key\n"
|
||||
" models:\n"
|
||||
" - local-a\n",
|
||||
provider_models={
|
||||
"openai": _model_list("gpt-4"),
|
||||
},
|
||||
)
|
||||
|
||||
result = config.get_available_models()
|
||||
group_ids = [g.get("provider_id") for g in result.get("groups", [])]
|
||||
|
||||
if "custom:mylocal" in group_ids:
|
||||
custom_idx = group_ids.index("custom:mylocal")
|
||||
for i, gid in enumerate(group_ids):
|
||||
if gid not in ("openai", "custom:mylocal"):
|
||||
assert custom_idx < i, (
|
||||
f"custom:mylocal at {custom_idx} should precede {gid} at {i}"
|
||||
)
|
||||
_teardown_config()
|
||||
|
||||
def test_configured_key_groups_before_no_key(self, tmp_path, monkeypatch):
|
||||
"""Providers with api_key in config sort before those without."""
|
||||
_setup_config(tmp_path, monkeypatch,
|
||||
"model:\n provider: openai\n default: gpt-4\n"
|
||||
"providers:\n"
|
||||
" openai:\n api_key: sk-oai-123\n"
|
||||
" deepseek:\n api_key: sk-ds-123\n"
|
||||
" google: {}\n",
|
||||
provider_models={
|
||||
"openai": _model_list("gpt-4"),
|
||||
"deepseek": _model_list("deepseek-chat"),
|
||||
"google": _model_list("gemini-pro"),
|
||||
},
|
||||
)
|
||||
|
||||
result = config.get_available_models()
|
||||
group_ids = [g.get("provider_id") for g in result.get("groups", [])]
|
||||
|
||||
if "deepseek" in group_ids and "google" in group_ids:
|
||||
ds_idx = group_ids.index("deepseek")
|
||||
google_idx = group_ids.index("google")
|
||||
assert ds_idx < google_idx, (
|
||||
f"deepseek (has key) at {ds_idx} should precede google (no key) at {google_idx}"
|
||||
)
|
||||
_teardown_config()
|
||||
Reference in New Issue
Block a user