From 478f7749abc8f0fe08aeff1af0efc741adb2a819 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 22 Jul 2026 00:50:29 -0700 Subject: [PATCH] Release: #6107 per-model reasoning_efforts precedence (exp-v0.52.140) (#6417) * fix: honor per-model reasoning effort metadata * docs(changelog): stamp #6107 per-model reasoning_efforts precedence --------- Co-authored-by: Charles Inglis <6466275+CharlesMcquade@users.noreply.github.com> Co-authored-by: nesquena-hermes Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/config.py | 60 ++++++++++++---- ...est_reasoning_effort_model_capabilities.py | 68 ++++++++++++++++++- 3 files changed, 114 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 194a276d..16e49929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed +- **Per-model reasoning-effort metadata is now honored by the WebUI reasoning picker.** When resolving which reasoning modes a model exposes, a `models..reasoning_efforts` list (as Hermes Agent stores under `custom_providers[].models`) now takes precedence over the provider-wide `reasoning_efforts` list, falling back to the provider list and then heuristics. This lets a model with its own declared modes (e.g. an unrecognized custom model) expose them without a provider-wide override. Configs that only set a provider-level list are completely unaffected — output is byte-identical to before. Thanks @CharlesMcquade. (#6107) + - **Stream teardown no longer leaks per-stream runtime state on long-running servers.** When a stream ended, its `STREAM_SESSION_OWNERS` owner mapping and (for gateway turns) the `AGENT_INSTANCES` entry were retained, so a high-volume, long-lived server slowly accumulated dead per-stream bookkeeping. Teardown now unregisters the stream owner and drops the gateway agent instance. The offline-frame buffer is deliberately **not** cleared on teardown so a late reconnect can still replay buffered `token`/`done`/`stream_end` frames; buffers remain bounded (8,192 frames) and channels are reclaimed once outstanding reconnect references exit. Thanks @webtecnica. (#6389, #6351) - **Japanese UI is fully translated again.** The Japanese (`ja`) locale had drifted to English fallback across recent features (workspace panel, session sharing, extensions gallery, cron sentence-builder, composer, settings, and update flows); it is now refreshed to parity with the current English keys — 180 strings — so Japanese users no longer see a mixed-language UI. Thanks @koshikai. (#6403) diff --git a/api/config.py b/api/config.py index 015bb870..2002e923 100644 --- a/api/config.py +++ b/api/config.py @@ -3885,6 +3885,33 @@ def resolve_model_reasoning_efforts( return filtered +def _configured_reasoning_effort_lists(provider_entry, model_id: str) -> list: + """Return model-level then provider-level effort lists from config.""" + if not isinstance(provider_entry, dict): + return [] + + configured_lists = [] + models = provider_entry.get("models") + if isinstance(models, dict): + model_key = str(model_id or "").strip().lower() + model_entry = models.get(model_id) + if not isinstance(model_entry, dict) and model_key: + model_entry = next( + ( + metadata + for configured_id, metadata in models.items() + if str(configured_id).strip().lower() == model_key + and isinstance(metadata, dict) + ), + None, + ) + if isinstance(model_entry, dict): + configured_lists.append(model_entry.get("reasoning_efforts")) + + configured_lists.append(provider_entry.get("reasoning_efforts")) + return configured_lists + + def _resolve_model_reasoning_efforts_impl( model_id: str | None = None, provider_id: str | None = None, @@ -3918,29 +3945,32 @@ def _resolve_model_reasoning_efforts_impl( if _nested_route_reasoning_denied(hinted_model): return [] - # 0. Provider config: providers..reasoning_efforts or named - # custom_providers[].reasoning_efforts. When the user has explicitly listed - # valid efforts for a provider, return that list directly — no heuristics, - # no models.dev lookup. - # Only short-circuits when the filtered list is non-empty; an all-invalid - # list (e.g. typos) falls through to heuristics instead of hiding reasoning. - _re_list = None + # 0. Model/provider config: a models..reasoning_efforts list takes + # precedence over its provider-level reasoning_efforts list. Explicit valid + # config is authoritative — no heuristics or models.dev lookup. Invalid or + # empty model metadata falls through to the provider list, then heuristics. + _re_lists = [] try: if provider and provider.startswith("custom:"): for _entry in _custom_provider_entries(): if _custom_provider_slug_from_name(_entry.get("name")) == provider: - _re_list = _entry.get("reasoning_efforts") + _re_lists = _configured_reasoning_effort_lists( + _entry, hinted_model + ) break elif provider: _prov_entry = (cfg.get("providers") or {}).get(provider, {}) if isinstance(_prov_entry, dict): - _re_list = _prov_entry.get("reasoning_efforts") - if isinstance(_re_list, list) and _re_list: - _filtered = [str(x).strip().lower() for x in _re_list - if str(x).strip().lower() in {*VALID_REASONING_EFFORTS, "none"}] - _filtered = list(dict.fromkeys(_filtered)) - if _filtered: - return _filtered + _re_lists = _configured_reasoning_effort_lists( + _prov_entry, hinted_model + ) + for _re_list in _re_lists: + if isinstance(_re_list, list) and _re_list: + _filtered = [str(x).strip().lower() for x in _re_list + if str(x).strip().lower() in {*VALID_REASONING_EFFORTS, "none"}] + _filtered = list(dict.fromkeys(_filtered)) + if _filtered: + return _filtered except Exception: pass diff --git a/tests/test_reasoning_effort_model_capabilities.py b/tests/test_reasoning_effort_model_capabilities.py index d8016855..6ba82ebc 100644 --- a/tests/test_reasoning_effort_model_capabilities.py +++ b/tests/test_reasoning_effort_model_capabilities.py @@ -192,6 +192,63 @@ def test_named_custom_provider_config_reasoning_efforts(monkeypatch): monkeypatch.setitem(cfg.cfg, "custom_providers", original) +def test_named_custom_provider_model_reasoning_efforts_take_precedence(monkeypatch): + original = cfg.cfg.get("custom_providers") + monkeypatch.setitem( + cfg.cfg, + "custom_providers", + [ + { + "name": "llm-proxy", + "reasoning_efforts": ["high"], + "models": { + "Inkling": { + "reasoning_efforts": ["none", "low", "medium", "high", "xhigh"] + } + }, + } + ], + ) + try: + assert cfg.resolve_model_reasoning_efforts( + "inkling", + provider_id="custom:llm-proxy", + ) == ["none", "low", "medium", "high", "xhigh"] + assert cfg.resolve_model_reasoning_efforts( + "another-model", + provider_id="custom:llm-proxy", + ) == ["high"] + finally: + if original is None: + cfg.cfg.pop("custom_providers", None) + else: + monkeypatch.setitem(cfg.cfg, "custom_providers", original) + + +def test_model_reasoning_efforts_fall_back_to_provider_when_invalid(monkeypatch): + original = cfg.cfg.get("providers") + monkeypatch.setitem( + cfg.cfg, + "providers", + { + "wandb": { + "reasoning_efforts": ["none", "high"], + "models": {"inkling": {"reasoning_efforts": ["bogus", "typo"]}}, + } + }, + ) + try: + assert cfg.resolve_model_reasoning_efforts( + "inkling", + provider_id="wandb", + ) == ["none", "high"] + finally: + if original is None: + cfg.cfg.pop("providers", None) + else: + monkeypatch.setitem(cfg.cfg, "providers", original) + + def test_acp_guards_win_over_configured_reasoning_efforts(monkeypatch): original = cfg.cfg.get("providers") monkeypatch.setitem( @@ -216,7 +273,16 @@ def test_nested_route_deny_wins_over_configured_reasoning_efforts(monkeypatch): monkeypatch.setitem( cfg.cfg, "custom_providers", - [{"name": "agg", "reasoning_efforts": ["low", "high"]}], + [ + { + "name": "agg", + "reasoning_efforts": ["low", "high"], + "models": { + "vertex/gemini-image-1.0": {"reasoning_efforts": ["high"]}, + "vertex/gemini-embedding-001": {"reasoning_efforts": ["high"]}, + }, + } + ], ) try: for model in ("vertex/gemini-image-1.0", "vertex/gemini-embedding-001"):