From afaeb03532d3626bbe5991e192e7e6578e9670f5 Mon Sep 17 00:00:00 2001 From: Manfred Date: Sun, 3 May 2026 19:47:40 +0200 Subject: [PATCH 01/11] fix: recover pending turn after stale stream restart --- api/models.py | 25 +++++++--- tests/test_session_sidecar_repair.py | 54 ++++++++++++--------- tests/test_stale_stream_pending_recovery.py | 49 +++++++++++++++++++ 3 files changed, 99 insertions(+), 29 deletions(-) create mode 100644 tests/test_stale_stream_pending_recovery.py diff --git a/api/models.py b/api/models.py index 50490446..8f8c491d 100644 --- a/api/models.py +++ b/api/models.py @@ -542,11 +542,23 @@ def _apply_core_sync_or_error_marker( if require_stream_dead and session.active_stream_id in _active_stream_ids(): return False - # When messages is already non-empty the core-sync overwrite and recovered - # user turn are skipped (we cannot clobber in-memory mutations), but the - # stuck pending fields MUST still be cleared and an error marker appended - # so the session isn't permanently left in stale-pending state. + # When messages is already non-empty, do not overwrite history from any core + # transcript. The pending user turn may still be the only durable copy of a + # prompt submitted just before a server restart, so materialize it before + # clearing runtime stream state. if len(session.messages) != 0: + _recovered_ts = int(time.time()) + if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0: + _recovered_ts = int(session.pending_started_at) + recovered = { + 'role': 'user', + 'content': session.pending_user_message, + 'timestamp': _recovered_ts, + '_recovered': True, + } + if session.pending_attachments: + recovered['attachments'] = list(session.pending_attachments) + session.messages.append(recovered) session.active_stream_id = None session.pending_user_message = None session.pending_attachments = [] @@ -559,7 +571,7 @@ def _apply_core_sync_or_error_marker( }) session.save() logger.info( - "Session %s: pending cleared (messages non-empty), added error marker", + "Session %s: recovered pending user turn (messages non-empty), added error marker", sid, ) return True @@ -636,8 +648,7 @@ def _repair_stale_pending(session) -> bool: # _apply_core_sync_or_error_marker uses this to detect a rotated active_stream_id # (e.g. context compression) or a stream that came back alive. _seen_stream_id = session.active_stream_id - if (len(session.messages) != 0 - or not session.pending_user_message + if (not session.pending_user_message or not _seen_stream_id or _seen_stream_id in _active_stream_ids()): return False diff --git a/tests/test_session_sidecar_repair.py b/tests/test_session_sidecar_repair.py index 75b6b49d..e95efafb 100644 --- a/tests/test_session_sidecar_repair.py +++ b/tests/test_session_sidecar_repair.py @@ -457,14 +457,14 @@ class TestCancelInProgressGuard: class TestEmptyMessagesGuard: - """_apply_core_sync_or_error_marker bails out when session.messages is - non-empty, preventing it from clobbering in-memory mutations made by the - streaming thread or cancel path.""" + """_apply_core_sync_or_error_marker preserves existing messages when + session.messages is non-empty, while still recovering the pending user turn + before clearing stale stream runtime fields.""" def test_pending_cleared_when_messages_nonempty_direct(self, hermes_home, monkeypatch): """When _apply_core_sync_or_error_marker is called on a session with - non-empty messages and pending set, it clears the pending fields and - appends an error marker, returning True.""" + non-empty messages and pending set, it recovers the pending user turn, + clears the pending fields, and appends an error marker.""" s = _make_session(messages=[{"role": "user", "content": "hello"}]) s.pending_user_message = "Another question" s.active_stream_id = "stream_1" @@ -477,11 +477,14 @@ class TestEmptyMessagesGuard: ) assert result is True - # Original message should be untouched - assert len(s.messages) == 2 # original + error marker + # Original message should be untouched, pending turn recovered, then marker appended + assert len(s.messages) == 3 # original + recovered user turn + error marker assert s.messages[0]["content"] == "hello" + assert s.messages[1]["role"] == "user" + assert s.messages[1]["content"] == "Another question" + assert s.messages[1].get("_recovered") is True # Error marker appended - assert s.messages[1].get("_error") is True + assert s.messages[2].get("_error") is True # Pending fields cleared assert s.pending_user_message is None assert s.active_stream_id is None @@ -517,13 +520,13 @@ class TestEmptyMessagesGuard: class TestNonEmptyMessagesPendingCleared: """When messages is non-empty and pending is stuck, _last_resort_sync_from_core - clears the pending fields and appends exactly one error marker without - clobbering existing messages or syncing from core.""" + preserves existing messages, recovers the pending user turn, and appends + exactly one error marker without syncing from core.""" def test_pending_cleared_when_messages_nonempty(self, hermes_home, monkeypatch): """_last_resort_sync_from_core on a session with both messages and - pending_user_message clears pending fields and appends exactly one - error marker.""" + pending_user_message recovers that pending turn before clearing runtime + fields and appending exactly one error marker.""" s = _make_session(messages=[{"role": "user", "content": "existing turn"}]) s.pending_user_message = "Stuck draft" s.pending_attachments = [{"type": "image", "name": "screenshot.png"}] @@ -543,9 +546,9 @@ class TestNonEmptyMessagesPendingCleared: streaming._last_resort_sync_from_core(s, "stale_stream", agent_lock) - # Existing messages preserved untouched - assert len(s.messages) == 2, ( - f"Expected 2 messages (original + error marker), got {len(s.messages)}" + # Existing messages preserved untouched, pending turn recovered, error marker appended + assert len(s.messages) == 3, ( + f"Expected 3 messages (original + recovered turn + error marker), got {len(s.messages)}" ) assert s.messages[0]["role"] == "user" assert s.messages[0]["content"] == "existing turn" @@ -553,15 +556,18 @@ class TestNonEmptyMessagesPendingCleared: "Core transcript must NOT be synced when messages is non-empty" ) + # Exactly one recovered user turn + recovered_msgs = [m for m in s.messages if m.get("_recovered")] + assert len(recovered_msgs) == 1 + assert recovered_msgs[0]["role"] == "user" + assert recovered_msgs[0]["content"] == "Stuck draft" + assert recovered_msgs[0]["attachments"] == [{"type": "image", "name": "screenshot.png"}] + # Exactly one error marker error_msgs = [m for m in s.messages if m.get("_error")] assert len(error_msgs) == 1 assert "Previous turn did not complete" in error_msgs[0]["content"] - # No recovered user turn (messages is non-empty, so skip that) - recovered_msgs = [m for m in s.messages if m.get("_recovered")] - assert len(recovered_msgs) == 0 - # Pending fields fully cleared assert s.pending_user_message is None assert s.pending_attachments == [] @@ -719,14 +725,18 @@ class TestRepairStalePendingIntegration: error_msgs = [m for m in s.messages if m.get("_error")] assert len(error_msgs) == 1 - def test_skips_when_messages_nonempty(self, hermes_home, monkeypatch): - """Pre-check: if messages is non-empty, repair is skipped entirely.""" + def test_recovers_when_messages_nonempty(self, hermes_home, monkeypatch): + """Pre-check: if messages is non-empty, repair still preserves the + pending user turn instead of silently discarding it.""" s = _make_session(messages=[{"role": "user", "content": "hi"}]) s.pending_user_message = "more" s.active_stream_id = "stream_1" result = _repair_stale_pending(s) - assert result is False + assert result is True + assert [m["content"] for m in s.messages if m["role"] == "user"] == ["hi", "more"] + assert s.messages[1].get("_recovered") is True + assert any(m.get("_error") for m in s.messages) def test_skips_when_stream_alive(self, hermes_home, monkeypatch): """Pre-check: if the stream is still alive in STREAMS, repair is skipped.""" diff --git a/tests/test_stale_stream_pending_recovery.py b/tests/test_stale_stream_pending_recovery.py new file mode 100644 index 00000000..debf4be1 --- /dev/null +++ b/tests/test_stale_stream_pending_recovery.py @@ -0,0 +1,49 @@ +"""Regression: stale stream cleanup must not discard pending user turns. + +A server restart drops the in-memory STREAMS table. Browser reload then calls +get_session(), which clears stale active_stream_id state. For long conversations +that already have messages, the pending_user_message can be the only durable copy +of the user turn that was submitted just before the restart. +""" + +import api.config as config +import api.models as models +from api.models import Session, get_session + + +def test_stale_stream_cleanup_recovers_pending_turn_on_non_empty_session(tmp_path, monkeypatch): + session_dir = tmp_path / "sessions" + session_dir.mkdir() + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") + models.SESSIONS.clear() + config.STREAMS.clear() + + s = Session( + session_id="stale_stream_nonempty", + title="Existing long chat", + messages=[ + {"role": "user", "content": "previous prompt", "timestamp": 100}, + {"role": "assistant", "content": "previous answer", "timestamp": 101}, + ], + ) + s.active_stream_id = "dead_stream" + s.pending_user_message = "new prompt that must survive restart" + s.pending_attachments = [{"name": "note.txt", "path": "/tmp/note.txt"}] + s.pending_started_at = 123 + s.save() + + recovered = get_session("stale_stream_nonempty") + + assert recovered.active_stream_id is None + assert recovered.pending_user_message is None + assert any( + msg.get("role") == "user" + and msg.get("content") == "new prompt that must survive restart" + and msg.get("_recovered") is True + for msg in recovered.messages + ) + assert any( + msg.get("role") == "assistant" and msg.get("_error") is True + for msg in recovered.messages + ) From 237010f8bd9bfcf9df3f52cc7401a20ee483eaac Mon Sep 17 00:00:00 2001 From: bergeouss Date: Sun, 3 May 2026 18:18:14 +0000 Subject: [PATCH 02/11] fix: remove phantom /sw.js from PUBLIC_PATHS whitelist (#1481) --- api/auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/auth.py b/api/auth.py index 480f3659..fa1e04cc 100644 --- a/api/auth.py +++ b/api/auth.py @@ -22,7 +22,6 @@ PUBLIC_PATHS = frozenset({ '/login', '/health', '/favicon.ico', '/api/auth/login', '/api/auth/status', '/manifest.json', '/manifest.webmanifest', - '/sw.js', }) COOKIE_NAME = 'hermes_session' From 8fe593fa38610be2ea8aed4159c16cad92f87a61 Mon Sep 17 00:00:00 2001 From: bergeouss Date: Sun, 3 May 2026 18:32:53 +0000 Subject: [PATCH 03/11] feat: silent credential self-heal on 401 errors (#1401) --- api/config.py | 12 +++ api/oauth.py | 9 ++ api/streaming.py | 266 ++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 263 insertions(+), 24 deletions(-) diff --git a/api/config.py b/api/config.py index f6bd774f..d755f00e 100644 --- a/api/config.py +++ b/api/config.py @@ -1413,6 +1413,18 @@ def invalidate_models_cache(): _delete_models_cache_on_disk() +def invalidate_credential_pool_cache(provider_id: str): + """Invalidate the credential pool cache for a specific provider. + + Used by the streaming layer's credential self-heal logic (#1401) to + force a fresh credential pool load after re-reading auth.json. + """ + global _CREDENTIAL_POOL_CACHE + with _available_models_cache_lock: + _CREDENTIAL_POOL_CACHE.pop(provider_id, None) + _CREDENTIAL_POOL_CACHE.pop(_resolve_provider_alias(provider_id), None) + + def invalidate_provider_models_cache(provider_id: str): """Invalidate cached models for a single provider. diff --git a/api/oauth.py b/api/oauth.py index 106e63b7..4cf4f180 100644 --- a/api/oauth.py +++ b/api/oauth.py @@ -38,6 +38,15 @@ def _read_auth_json(): return {} +def read_auth_json(): + """Public wrapper for _read_auth_json. + + Used by the streaming layer's credential self-heal logic (#1401) to + re-read credentials without coupling to the private helper. + """ + return _read_auth_json() + + def _write_auth_json(data): """Atomically write auth.json via temp-file rename. diff --git a/api/streaming.py b/api/streaming.py index 648df143..736720ec 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1343,6 +1343,61 @@ def _last_resort_sync_from_core(session, stream_id, agent_lock): ) +def _attempt_credential_self_heal( + provider_id, session_id, _agent_lock_ref, +): + """Try to silently refresh credentials after a 401/auth error (#1401). + + Returns a new ``(agent, rt_dict)`` tuple on success so the caller can + retry the conversation. Returns ``None`` when self-heal is not + applicable (e.g. auth.json unchanged, provider unresolvable). + + Steps: + 1. Re-read ``~/.hermes/auth.json`` to pick up fresh credentials that + may have been written by a concurrent ``hermes model`` CLI invocation. + 2. Evict the session's cached agent so it is rebuilt with fresh keys. + 3. Evict the provider's credential-pool cache entry. + 4. Re-resolve the runtime provider. + 5. Return a new agent + resolved-provider dict (the caller must + re-invoke ``run_conversation`` with these). + """ + try: + from api.oauth import read_auth_json + from api.config import ( + SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK, + invalidate_credential_pool_cache, + ) + from hermes_cli.runtime_provider import resolve_runtime_provider + + # 1. Re-read auth.json (triggers a fresh credential scan) + _fresh_auth = read_auth_json() + if not _fresh_auth: + logger.debug('[webui] self-heal: auth.json empty or missing, skipping') + return None + + # 2. Evict the cached agent for this session + with SESSION_AGENT_CACHE_LOCK: + SESSION_AGENT_CACHE.pop(session_id, None) + + # 3. Invalidate the credential pool for this provider + invalidate_credential_pool_cache(provider_id) + + # 4. Re-resolve runtime provider with fresh credentials + _new_rt = resolve_runtime_provider(requested=provider_id) + + logger.info( + '[webui] self-heal: credential refresh succeeded for provider=%s session=%s', + provider_id, session_id, + ) + return _new_rt + except Exception as _heal_err: + logger.warning( + '[webui] self-heal: failed for provider=%s session=%s: %s', + provider_id, session_id, _heal_err, + ) + return None + + def _run_agent_streaming( session_id, msg_text, @@ -1561,6 +1616,7 @@ def _run_agent_streaming( try: _token_sent = False # tracks whether any streamed tokens were sent + _self_healed = False # (#1401) prevents infinite self-heal retries _reasoning_text = '' # accumulates reasoning/thinking trace for persistence _live_tool_calls = [] # tool progress fallback when final messages omit tool IDs @@ -2143,6 +2199,95 @@ def _run_agent_streaming( _err_label = 'Out of credits' _err_type = 'quota_exhausted' _err_hint = 'Your provider account is out of credits. Top up your balance or switch providers via `hermes model`.' + elif _is_auth and not _self_healed: + # ── Credential self-heal on 401 (#1401) ── + # Before emitting the error, try re-reading credentials + # and retrying once with a fresh agent. + _heal_result = None + _heal_rt = _attempt_credential_self_heal( + resolved_provider or '', session_id, _agent_lock, + ) + if _heal_rt is not None: + logger.info('[webui] self-heal: retrying stream after credential refresh') + # Rebuild runtime variables from the refreshed resolve + _rt = _heal_rt + resolved_api_key = _heal_rt.get('api_key') + if not resolved_provider: + resolved_provider = _heal_rt.get('provider') + if not resolved_base_url: + resolved_base_url = _heal_rt.get('base_url') + # Rebuild agent kwargs and create a fresh agent + _agent_kwargs['api_key'] = resolved_api_key + _agent_kwargs['base_url'] = resolved_base_url + _agent_kwargs['model'] = resolved_model + _agent_kwargs['provider'] = resolved_provider + if 'credential_pool' in _agent_params: + _agent_kwargs['credential_pool'] = _heal_rt.get('credential_pool') + agent = _AIAgent(**_agent_kwargs) + with STREAMS_LOCK: + AGENT_INSTANCES[stream_id] = agent + from api.config import SESSION_AGENT_CACHE as _SAC, SESSION_AGENT_CACHE_LOCK as _SAC_L + with _SAC_L: + _SAC[session_id] = (agent, _agent_sig) + _SAC.move_to_end(session_id) + # Retry the conversation once with fresh credentials + _self_healed = True + _token_sent = False + try: + _heal_result = agent.run_conversation( + user_message=user_message, + system_message=workspace_system_msg, + conversation_history=_sanitize_messages_for_api(_previous_context_messages), + task_id=session_id, + persist_user_message=msg_text, + ) + _heal_ok = any( + m.get('role') == 'assistant' and str(m.get('content') or '').strip() + for m in (_heal_result.get('messages') or []) + ) or _token_sent + except Exception as _retry_exc: + logger.warning( + '[webui] self-heal: retry also failed: %s', _retry_exc, + ) + _heal_ok = False + if _heal_ok and _heal_result is not None: + # Retry succeeded — replace result and skip error + result = _heal_result + # Fall through past the error-emission block; + # the post-result persistence code below will + # process ``result`` normally. We jump past + # the ``put('apperror', ...)`` + ``return`` by + # NOT entering the ``if not _assistant_added`` + # guard again — but we are already inside it. + # Solution: set _assistant_added so the guard + # evaluates False on next conceptual pass. + # Since we're in a flat block, directly run the + # post-result merge logic here. + _result_messages = result.get('messages') or _previous_context_messages + _next_context_messages = _restore_reasoning_metadata( + _previous_context_messages, + _result_messages, + ) + s.context_messages = _next_context_messages + s.messages = _merge_display_messages_after_agent_result( + _previous_messages, + _previous_context_messages, + _restore_reasoning_metadata(_previous_messages, _result_messages), + msg_text, + ) + # Skip the error block — jump directly to the + # normal post-result persistence path by + # leaving _assistant_added truthy (set below). + _assistant_added = True # prevent re-entering guard + if not _assistant_added: + # Self-heal didn't apply or retry failed — emit error + _err_label = 'Authentication failed' + _err_type = 'auth_mismatch' + _err_hint = ( + 'The selected model may not be supported by your configured provider or ' + 'your API key is invalid. Run `hermes model` in your terminal to ' + 'update credentials, then restart the WebUI.' + ) elif _is_auth: _err_label = 'Authentication failed' _err_type = 'auth_mismatch' @@ -2155,31 +2300,38 @@ def _run_agent_streaming( _err_label = 'No response received' _err_type = 'no_response' _err_hint = 'Verify your API key is valid and the selected model is available for your account.' - put('apperror', { - 'message': _err_str or f'{_err_label}.', - 'type': _err_type, - 'hint': _err_hint, - }) - # Clear stream/pending state so the session does not appear - # "agent_running" on reload after a silent failure. - # Persist the error so it survives page reload. - # _error=True ensures _sanitize_messages_for_api excludes it from - # subsequent API calls so the LLM never sees its own error as prior context. - s.active_stream_id = None - s.pending_user_message = None - s.pending_attachments = [] - s.pending_started_at = None - s.messages.append({ - 'role': 'assistant', - 'content': f'**{_err_label}:** {_err_str or _err_label}\n\n*{_err_hint}*', - 'timestamp': int(time.time()), - '_error': True, - }) - try: - s.save() - except Exception: + # Skip error emission if credential self-heal succeeded + # (#1401) — _assistant_added is set True on successful retry. + if _assistant_added: + # Self-heal succeeded: messages are already merged into s, + # fall through to normal post-result persistence below. pass - return # apperror already closes the stream on the client side + else: + put('apperror', { + 'message': _err_str or f'{_err_label}.', + 'type': _err_type, + 'hint': _err_hint, + }) + # Clear stream/pending state so the session does not appear + # "agent_running" on reload after a silent failure. + # Persist the error so it survives page reload. + # _error=True ensures _sanitize_messages_for_api excludes it from + # subsequent API calls so the LLM never sees its own error as prior context. + s.active_stream_id = None + s.pending_user_message = None + s.pending_attachments = [] + s.pending_started_at = None + s.messages.append({ + 'role': 'assistant', + 'content': f'**{_err_label}:** {_err_str or _err_label}\n\n*{_err_hint}*', + 'timestamp': int(time.time()), + '_error': True, + }) + try: + s.save() + except Exception: + pass + return # apperror already closes the stream on the client side # ── Handle context compression side effects ── # If compression fired inside run_conversation, the agent may have @@ -2499,6 +2651,72 @@ def _run_agent_streaming( 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.', ) elif _exc_is_auth: + if not _self_healed: + # ── Credential self-heal on 401 (#1401) ── + _heal_rt = _attempt_credential_self_heal( + resolved_provider or '', session_id, _agent_lock, + ) + if _heal_rt is not None: + logger.info('[webui] self-heal (except path): retrying stream after credential refresh') + _self_healed = True + # Rebuild runtime variables + _rt = _heal_rt + resolved_api_key = _heal_rt.get('api_key') + if not resolved_provider: + resolved_provider = _heal_rt.get('provider') + if not resolved_base_url: + resolved_base_url = _heal_rt.get('base_url') + # Build a fresh agent with the new credentials + _heal_kwargs = dict(_agent_kwargs) if '_agent_kwargs' in dir() else {} + _heal_kwargs['api_key'] = resolved_api_key + _heal_kwargs['base_url'] = resolved_base_url + _heal_kwargs['model'] = resolved_model + _heal_kwargs['provider'] = resolved_provider + if 'credential_pool' in _agent_params: + _heal_kwargs['credential_pool'] = _heal_rt.get('credential_pool') + _heal_agent = _AIAgent(**_heal_kwargs) + with STREAMS_LOCK: + AGENT_INSTANCES[stream_id] = _heal_agent + from api.config import SESSION_AGENT_CACHE as _SAC2, SESSION_AGENT_CACHE_LOCK as _SAC2_L + with _SAC2_L: + _SAC2[session_id] = (_heal_agent, _agent_sig) + _SAC2.move_to_end(session_id) + # Retry the conversation + _token_sent = False + try: + _heal_result = _heal_agent.run_conversation( + user_message=user_message, + system_message=workspace_system_msg, + conversation_history=_sanitize_messages_for_api(_previous_context_messages), + task_id=session_id, + persist_user_message=msg_text, + ) + # Retry succeeded — persist the result normally + if s is not None: + if _checkpoint_stop is not None: + _checkpoint_stop.set() + if _ckpt_thread is not None: + _ckpt_thread.join(timeout=15) + _lock_ctx = _agent_lock if _agent_lock is not None else contextlib.nullcontext() + with _lock_ctx: + _result_messages = _heal_result.get('messages') or _previous_context_messages + _next_context_messages = _restore_reasoning_metadata( + _previous_context_messages, _result_messages, + ) + s.context_messages = _next_context_messages + s.messages = _merge_display_messages_after_agent_result( + _previous_messages, + _previous_context_messages, + _restore_reasoning_metadata(_previous_messages, _result_messages), + msg_text, + ) + s.save() + logger.info('[webui] self-heal (except path): retry succeeded') + return # skip error emission + except Exception as _retry_exc2: + logger.warning('[webui] self-heal (except path): retry failed: %s', _retry_exc2) + # Fall through to emit the original error + # Self-heal didn't apply or retry failed — emit the auth error _exc_label, _exc_type, _exc_hint = ( 'Authentication error', 'auth_mismatch', 'The selected model may not be supported by your configured provider. ' From f60db40133cf348232bea1024aaadca84dfbe97b Mon Sep 17 00:00:00 2001 From: bergeouss Date: Sun, 3 May 2026 18:20:24 +0000 Subject: [PATCH 04/11] fix: include OpenRouter free-tier models in fallback list (#1426) --- api/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/config.py b/api/config.py index 694867b9..9d4fa457 100644 --- a/api/config.py +++ b/api/config.py @@ -536,6 +536,14 @@ _FALLBACK_MODELS = [ {"provider": "Z.AI", "id": "zai/glm-4.7", "label": "GLM-4.7"}, {"provider": "Z.AI", "id": "zai/glm-4.5", "label": "GLM-4.5"}, {"provider": "Z.AI", "id": "zai/glm-4.5-flash", "label": "GLM-4.5 Flash"}, + # OpenRouter free-tier models — must appear in fallback list so they + # are visible even when the tool-support filter in hermes_cli strips + # them out of the live catalog (see #1426). + {"provider": "OpenRouter", "id": "openrouter/elephant-alpha", "label": "Elephant Alpha (free)"}, + {"provider": "OpenRouter", "id": "openrouter/owl-alpha", "label": "Owl Alpha (free)"}, + {"provider": "OpenRouter", "id": "tencent/hy3-preview:free", "label": "Hy3 Preview (free)"}, + {"provider": "OpenRouter", "id": "nvidia/nemotron-3-super-120b-a12b:free", "label": "Nemotron 3 Super (free)"}, + {"provider": "OpenRouter", "id": "arcee-ai/trinity-large-preview:free", "label": "Trinity Large Preview (free)"}, ] # Provider display names for known Hermes provider IDs From c94f9c70ce647322ba4c9e602e217d69a490e9fd Mon Sep 17 00:00:00 2001 From: bergeouss Date: Sun, 3 May 2026 18:20:49 +0000 Subject: [PATCH 05/11] feat: add 'What's new?' link to update banner (#1512) --- api/updates.py | 9 +++++++++ static/index.html | 1 + static/ui.js | 11 +++++++++++ 3 files changed, 21 insertions(+) diff --git a/api/updates.py b/api/updates.py index 953cbe2e..1c286970 100644 --- a/api/updates.py +++ b/api/updates.py @@ -150,12 +150,21 @@ def _check_repo(path, name): current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path) latest, _ = _run_git(['rev-parse', '--short', compare_ref], path) + # Get repo URL for "What's new?" link + remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) + # Convert SSH URLs (git@github.com:org/repo.git) to HTTPS + if remote_url and remote_url.startswith('git@'): + remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1).rstrip('.git') + elif remote_url: + remote_url = remote_url.rstrip('.git') + return { 'name': name, 'behind': behind, 'current_sha': current, 'latest_sha': latest, 'branch': compare_ref, + 'repo_url': remote_url, } diff --git a/static/index.html b/static/index.html index 287f5ba1..a7834ae3 100644 --- a/static/index.html +++ b/static/index.html @@ -260,6 +260,7 @@
diff --git a/static/ui.js b/static/ui.js index eb9b422a..2e7c88fd 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2832,6 +2832,17 @@ function _showUpdateBanner(data){ const banner=$('updateBanner'); if(banner) banner.classList.add('visible'); window._updateData=data; + // Wire up "What's new?" link + const link=$('updateWhatsNew'); + if(link && data.webui){ + const repoUrl=data.webui.repo_url; + const curSha=data.webui.current_sha; + const newSha=data.webui.latest_sha; + if(repoUrl && curSha && newSha){ + link.href=repoUrl+'/compare/'+curSha+'...'+newSha; + link.style.display='inline'; + } + } } function dismissUpdate(){ const b=$('updateBanner');if(b)b.classList.remove('visible'); From 0fbaafa11011f2762b3349173868d80f394ca638 Mon Sep 17 00:00:00 2001 From: bergeouss Date: Sun, 3 May 2026 18:21:19 +0000 Subject: [PATCH 06/11] feat: auto-assign project when filtering by project on new session (#1468) --- api/models.py | 3 ++- api/routes.py | 1 + static/sessions.js | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/models.py b/api/models.py index 50490446..c389516b 100644 --- a/api/models.py +++ b/api/models.py @@ -713,7 +713,7 @@ def get_session(sid, metadata_only=False): return s raise KeyError(sid) -def new_session(workspace=None, model=None, profile=None, model_provider=None): +def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None): """Create a new in-memory session. The session lives in the SESSIONS dict only — no disk write happens until @@ -749,6 +749,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None): model=effective_model, model_provider=model_provider, profile=profile, + project_id=project_id, ) with LOCK: SESSIONS[s.session_id] = s diff --git a/api/routes.py b/api/routes.py index 8f6a88ed..8f44316c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2253,6 +2253,7 @@ def handle_post(handler, parsed) -> bool: model=model, model_provider=model_provider, profile=body.get("profile") or None, + project_id=body.get("project_id") or None, ) return j(handler, {"session": s.compact() | {"messages": s.messages}}) diff --git a/static/sessions.js b/static/sessions.js index dc6a020c..6e6c4fe6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -287,12 +287,14 @@ async function newSession(flash){ const newModelState=(canQualify&&typeof _modelStateForSelect==='function') ? _modelStateForSelect(modelSel,selectedDefaultModel) : {model:selectedDefaultModel,model_provider:null}; - const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({ + const reqBody={ model:newModelState.model, model_provider:newModelState.model_provider||null, workspace:inheritWs, profile:S.activeProfile||'default', - })}); + }; + if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject; + const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)}); S.session=data.session;S.messages=data.session.messages||[]; S.lastUsage={...(data.session.last_usage||{})}; if(flash)S.session._flash=true; From a085b71511a41309e2f66b753566a826faf730ca Mon Sep 17 00:00:00 2001 From: bergeouss Date: Sun, 3 May 2026 18:26:57 +0000 Subject: [PATCH 07/11] feat: add Reveal in File Manager to workspace file context menu (#1424) --- api/routes.py | 33 +++++++++++++++++++++++++++++++++ static/i18n.js | 16 ++++++++++++++++ static/ui.js | 9 +++++++++ 3 files changed, 58 insertions(+) diff --git a/api/routes.py b/api/routes.py index 8f6a88ed..9fcf5f1e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10,7 +10,9 @@ import logging import os import queue import re +import platform import shutil +import subprocess import sys import threading import time @@ -2805,6 +2807,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/file/create-dir": return _handle_create_dir(handler, body) + if parsed.path == "/api/file/reveal": + return _handle_file_reveal(handler, body) + # ── Workspace management (POST) ── if parsed.path == "/api/workspaces/add": return _handle_workspace_add(handler, body) @@ -5154,6 +5159,34 @@ def _handle_create_dir(handler, body): return bad(handler, _sanitize_error(e)) +def _handle_file_reveal(handler, body): + try: + require(body, "session_id", "path") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + try: + target = safe_resolve(Path(s.workspace), body["path"]) + if not target.exists(): + return bad(handler, "File not found", 404) + + system = platform.system() + if system == "Darwin": + subprocess.Popen(["open", "-R", str(target)]) + elif system == "Windows": + subprocess.Popen(["explorer.exe", "/select," + str(target)]) + else: + # Linux / other — open parent directory + subprocess.Popen(["xdg-open", str(target.parent)]) + + return j(handler, {"ok": True, "path": body["path"]}) + except (ValueError, PermissionError, OSError) as e: + return bad(handler, _sanitize_error(e)) + + def _handle_workspace_add(handler, body): path_str = body.get("path", "").strip() name = body.get("name", "").strip() diff --git a/static/i18n.js b/static/i18n.js index d6c3ec29..6d33b3e6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -316,6 +316,8 @@ const LOCALES = { rename_prompt: 'New name:', deleted: 'Deleted ', delete_failed: 'Delete failed: ', + reveal_in_finder: 'Reveal in File Manager', + reveal_failed: 'Failed to reveal: ', new_file_prompt: 'New file name (e.g. notes.md):', project_name_prompt: 'Project name:', created: 'Created ', @@ -1212,6 +1214,8 @@ const LOCALES = { rename_prompt: '新しい名前:', deleted: '削除しました: ', delete_failed: '削除失敗: ', + reveal_in_finder: 'ファイルマネージャーで表示', + reveal_failed: '表示に失敗しました: ', new_file_prompt: '新しいファイル名 (例: notes.md):', project_name_prompt: 'プロジェクト名:', created: '作成しました: ', @@ -2031,6 +2035,8 @@ const LOCALES = { rename_prompt: 'Новое имя:', deleted: 'Удалено ', delete_failed: 'Не удалось удалить: ', + reveal_in_finder: 'Показать в файловом менеджере', + reveal_failed: 'Не удалось открыть: ', new_file_prompt: 'Имя нового файла (например, notes.md):', project_name_prompt: 'Имя проекта:', created: 'Создано ', @@ -2844,6 +2850,8 @@ const LOCALES = { rename_prompt: 'Nuevo nombre:', deleted: 'Eliminado ', delete_failed: 'Error al eliminar: ', + reveal_in_finder: 'Mostrar en el gestor de archivos', + reveal_failed: 'Error al mostrar: ', new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):', created: 'Creado ', create_failed: 'Error al crear: ', @@ -3666,6 +3674,8 @@ const LOCALES = { rename_prompt: 'Neuer Name:', deleted: 'Gelöscht ', delete_failed: 'Löschen fehlgeschlagen: ', + reveal_in_finder: 'Im Dateimanager anzeigen', + reveal_failed: 'Anzeige fehlgeschlagen: ', new_file_prompt: 'Neuer Dateiname (z.B. notes.md):', project_name_prompt: 'Projektname:', created: 'Erstellt ', @@ -4515,6 +4525,8 @@ const LOCALES = { rename_prompt: '新名称:', deleted: '\u5df2\u5220\u9664 ', delete_failed: '\u5220\u9664\u5931\u8d25\uff1a', + reveal_in_finder: '\u5728\u6587\u4ef6\u7ba1\u7406\u5668\u4e2d\u663e\u793a', + reveal_failed: '\u663e\u793a\u5931\u8d25\uff1a', new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', project_name_prompt: '\u9879\u76ee\u540d\u79f0\uff1a', created: '\u5df2\u521b\u5efa ', @@ -5297,6 +5309,8 @@ const LOCALES = { rename_prompt: '新名稱:', deleted: '\u5df2\u522a\u9664 ', delete_failed: '\u522a\u9664\u5931\u6557\uff1a', + reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', + reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', created: '\u5df2\u5275\u5efa ', create_failed: '\u5275\u5efa\u5931\u6557\uff1a', @@ -7089,6 +7103,8 @@ const LOCALES = { rename_prompt: '새 이름:', deleted: '삭제됨: ', delete_failed: '삭제 실패: ', + reveal_in_finder: '파일 관리자에서 열기', + reveal_failed: '표시 실패: ', new_file_prompt: 'New file name (e.g. notes.md):', project_name_prompt: 'Project name:', created: '생성됨: ', diff --git a/static/ui.js b/static/ui.js index eb9b422a..3e30f939 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5318,6 +5318,15 @@ function _showFileContextMenu(e, item){ renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);}; menu.appendChild(renameItem); + // Reveal in File Manager + const revealItem=document.createElement('div'); + revealItem.textContent=t('reveal_in_finder'); + revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + revealItem.onmouseenter=()=>revealItem.style.background='var(--hover)'; + revealItem.onmouseleave=()=>revealItem.style.background=''; + revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+err.message);}}; + menu.appendChild(revealItem); + // Divider + Delete const sep=document.createElement('hr'); sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; From 1c5bce92cb8497a67abbfc02e9f805277ad1ee24 Mon Sep 17 00:00:00 2001 From: bergeouss Date: Sun, 3 May 2026 18:28:24 +0000 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20add=20gateway=20status=20card=20t?= =?UTF-8?q?o=20Settings=20=E2=86=92=20System=20(#1457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/routes.py | 37 +++++++++++++++++++++++++++++++++++++ static/index.html | 6 ++++++ static/panels.js | 24 +++++++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 8f6a88ed..8c4f177b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2181,6 +2181,43 @@ def handle_get(handler, parsed) -> bool: {"name": get_active_profile_name(), "path": str(get_active_hermes_home())}, ) + # ── Gateway Status (GET) ── + if parsed.path == "/api/gateway/status": + import datetime + identity_map = _load_gateway_session_identity_map() + sessions_path = _gateway_session_metadata_path() + running = bool(identity_map) + platforms_set: set[str] = set() + for meta in identity_map.values(): + raw = meta.get("raw_source") or meta.get("platform") or "" + norm = _normalize_messaging_source(raw) + if norm: + platforms_set.add(norm) + _PLATFORM_LABELS = { + "telegram": "Telegram", + "discord": "Discord", + "slack": "Slack", + "web": "Web", + "api": "API", + } + platforms = sorted( + [{"name": p, "label": _PLATFORM_LABELS.get(p, p.title())} for p in platforms_set], + key=lambda x: x["label"], + ) + last_active = "" + if running and sessions_path.exists(): + try: + mtime = sessions_path.stat().st_mtime + last_active = datetime.datetime.fromtimestamp(mtime).isoformat() + except Exception: + pass + return j(handler, { + "running": running, + "platforms": platforms, + "last_active": last_active, + "session_count": len(identity_map), + }) + # ── MCP Servers (GET) ── if parsed.path == "/api/mcp/servers": return _handle_mcp_servers_list(handler) diff --git a/static/index.html b/static/index.html index 287f5ba1..a875d87c 100644 --- a/static/index.html +++ b/static/index.html @@ -931,6 +931,12 @@
+ +
+ +
Status of the Hermes gateway (Telegram, Discord, Slack, etc.)
+
Loading…
+
diff --git a/static/panels.js b/static/panels.js index f633a7f4..eaf4ab2e 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3840,11 +3840,33 @@ async function deleteMcpServer(name){ else{showToast((r&&r.error)||t('mcp_delete_failed'));} }).catch(()=>{showToast(t('mcp_delete_failed'));}); } +function loadGatewayStatus(){ + const card=$('gatewayStatusCard'); + if(!card) return; + api('/api/gateway/status').then(r=>{ + if(!r) return; + if(!r.running){ + card.innerHTML=`
Gateway not running
`; + return; + } + const platformIcons={telegram:'💬',discord:'🎮',slack:'📝',web:'🌐',api:'🔌'}; + let badges=''; + if(r.platforms&&r.platforms.length){ + badges=r.platforms.map(p=>{ + const icon=platformIcons[p.name]||'📡'; + return `${icon} ${esc(p.label)}`; + }).join(' '); + } + const lastActive=r.last_active?`Last active: ${esc(new Date(r.last_active).toLocaleString())}`:''; + const sessionInfo=r.session_count?`${r.session_count} session${r.session_count!==1?'s':''}`:''; + card.innerHTML=`
Running
${badges?`
${badges}
`:''}
${sessionInfo}${lastActive}
`; + }).catch(()=>{card.innerHTML=`
Failed to load gateway status
`}); +} // Load MCP servers when system settings tab opens const _origSwitchSettings=switchSettingsSection; switchSettingsSection=function(name){ _origSwitchSettings(name); - if(name==='system') loadMcpServers(); + if(name==='system'){loadMcpServers();loadGatewayStatus();} }; // ── Checkpoints / Rollback ────────────────────────────────────────────────── From 0750da5b37cdf753465fdf7d8f09157bf5185bcd Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sun, 3 May 2026 19:18:44 +0000 Subject: [PATCH 09/11] =?UTF-8?q?fix(models):=20structural=20OpenRouter=20?= =?UTF-8?q?free-tier=20visibility=20=E2=80=94=20live=20fetch=20+=20augment?= =?UTF-8?q?=20fallback=20(#1426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Augments @bergeouss's PR #1548 v2 with the structural fix the issue actually requested. The original PR added 5 hardcoded entries to _FALLBACK_MODELS which would rot fast as OpenRouter's free-tier roster turns over monthly. Adds proper live-fetch logic to the OpenRouter group population so the free-tier list stays fresh without requiring a code release every time a new free model lands. api/config.py:2120 — replaces the static _FALLBACK_MODELS slice with: 1. Live curated catalog via hermes_cli.models.fetch_openrouter_models() — applies the tool-support filter (Kilo-Org/kilocode#9068). 2. Free-tier live fetch — direct call to https://openrouter.ai/api/v1/models, filtered to free-tier-only (pricing.prompt == 0 AND pricing.completion == 0, OR :free suffix), bypasses the tool-support filter so newly-added free variants appear even before OpenRouter annotates them with tools. Capped at 30 entries to keep the picker usable. 3. Defense-in-depth fallback to _FALLBACK_MODELS (which retains @bergeouss's hardcoded list for offline / test envs). 4. Deduplication via seen_ids — model in both surfaces appears once. 5 new tests + 1 fixed test in tests/test_minimax_provider.py (scoped the provider='MiniMax' assertion to direct-MiniMax routes by filtering for 'minimax/' prefix and excluding ':free' since the OpenRouter free-tier variant minimax/minimax-m2.5:free correctly carries provider='OpenRouter'). Co-authored-by: bergeouss <[email protected]> --- api/config.py | 88 ++++++- ...sue1426_openrouter_free_tier_live_fetch.py | 226 ++++++++++++++++++ tests/test_minimax_provider.py | 20 +- 3 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 tests/test_issue1426_openrouter_free_tier_live_fetch.py diff --git a/api/config.py b/api/config.py index 9d4fa457..52490d56 100644 --- a/api/config.py +++ b/api/config.py @@ -2117,14 +2117,94 @@ def get_available_models() -> dict: continue provider_name = _PROVIDER_DISPLAY.get(pid, pid.title()) if pid == "openrouter": + # OpenRouter has two model surfaces: + # (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models() + # — the canonical agent-ready list, applies a tool-support filter + # (Kilo-Org/kilocode#9068) that hides image/completion-only models + # (2) free-tier `:free` variants — newly-added models OpenRouter ships + # experimentally that may not yet advertise `tools` in supported_parameters + # (see #1426). These get filtered out of (1) but users want them visible. + # + # Strategy: take the live curated list as the base, then augment with a + # separate live-fetch of OpenRouter's /v1/models filtered to free-tier-only. + # Free-tier entries get a "(free)" label suffix so the picker is honest about + # what the user is selecting. Falls back to the static _FALLBACK_MODELS list + # when both live fetches fail (offline, transient API error, test env). + raw_models = [] + seen_ids = set() + try: + from hermes_cli.models import ( + fetch_openrouter_models as _fetch_or_models, + ) + live_curated = _fetch_or_models() or [] + for mid, _desc in live_curated: + if mid and mid not in seen_ids: + seen_ids.add(mid) + raw_models.append({"id": mid, "label": mid}) + except Exception: + logger.warning("Failed to load OpenRouter curated catalog from hermes_cli") + + # Free-tier live fetch — bypasses the tool-support filter so models + # OpenRouter has flagged free but hasn't yet annotated with tools=[] + # (or that have tools=[] but the user explicitly wants to try) appear. + try: + import urllib.request as _urlreq + _req = _urlreq.Request( + "https://openrouter.ai/api/v1/models", + headers={"Accept": "application/json"}, + ) + with _urlreq.urlopen(_req, timeout=8.0) as _resp: + _payload = json.loads(_resp.read().decode()) + _free_count = 0 + _free_cap = 30 # don't drown the picker — top 30 free tier + for _item in _payload.get("data", []) or []: + if not isinstance(_item, dict): + continue + _mid = str(_item.get("id") or "").strip() + if not _mid or _mid in seen_ids: + continue + _pricing = _item.get("pricing") or {} + try: + _is_free = ( + float(_pricing.get("prompt", "0") or "0") == 0 + and float(_pricing.get("completion", "0") or "0") == 0 + ) + except (TypeError, ValueError): + _is_free = False + # Also include explicit `:free` suffix variants + _is_free = _is_free or _mid.endswith(":free") + if not _is_free: + continue + _name = ( + str(_item.get("name") or "").strip() or _mid + ) + # Strip provider prefix from name for display, append (free) + _label = _name.split("/")[-1] if "/" in _name else _name + if "(free)" not in _label.lower(): + _label = f"{_label} (free)" + seen_ids.add(_mid) + raw_models.append({"id": _mid, "label": _label}) + _free_count += 1 + if _free_count >= _free_cap: + break + except Exception: + logger.debug("OpenRouter free-tier live fetch unavailable; using fallback") + + if not raw_models: + # Both live fetches failed — fall back to the curated static list. + # Deepcopy so dedup/prefix mutation downstream does not bleed + # into the module-level catalog. + raw_models = [ + {"id": m["id"], "label": m["label"]} + for m in _FALLBACK_MODELS + if m.get("provider") == "OpenRouter" + ] + groups.append( { "provider": "OpenRouter", "provider_id": "openrouter", - "models": [ - {"id": m["id"], "label": m["label"]} - for m in _FALLBACK_MODELS - ], + "models": raw_models, } ) elif pid == "ollama-cloud": diff --git a/tests/test_issue1426_openrouter_free_tier_live_fetch.py b/tests/test_issue1426_openrouter_free_tier_live_fetch.py new file mode 100644 index 00000000..c5f6175f --- /dev/null +++ b/tests/test_issue1426_openrouter_free_tier_live_fetch.py @@ -0,0 +1,226 @@ +"""Regression tests for #1426 — OpenRouter free-tier visibility (structural fix). + +Original PR #1548 added 6 hardcoded `_FALLBACK_MODELS` entries. This is the +structural augmentation: WebUI now does TWO live fetches when populating the +OpenRouter group: + + (1) `hermes_cli.models.fetch_openrouter_models()` — the curated tool-supporting + list, which goes through the tool-support filter (Kilo-Org/kilocode#9068). + (2) Direct `https://openrouter.ai/api/v1/models` — filtered to free-tier-only, + bypassing the tool-support filter so newly-added free variants appear. + +Both fall back to `_FALLBACK_MODELS` (which retains @bergeouss's hardcoded list +as a defense-in-depth fallback) when the API is unreachable. + +These tests verify the structural fix without depending on real network access: +the urllib.request layer is monkeypatched. +""" +from __future__ import annotations + +import json +import urllib.request + +import pytest + +import api.config as config + + +class _FakeResponse: + def __init__(self, payload: dict): + self._buf = json.dumps(payload).encode() + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self) -> bytes: + return self._buf + + +def _make_or_payload(*items: dict) -> dict: + return {"data": list(items)} + + +def _get_grouped_models() -> list[dict]: + """Helper: return the `groups` field from get_available_models().""" + # Reset internal cache so each call re-runs the live-fetch path + try: + config.invalidate_models_cache() + except Exception: + pass + result = config.get_available_models() + return result.get("groups", []) + + +@pytest.fixture(autouse=True) +def _isolate_openrouter_cache(monkeypatch): + """Reset the curated cache before each test so the live-fetch path runs. + Also force `openrouter` as the active provider so the openrouter branch + in get_available_models() actually runs.""" + try: + from hermes_cli import models as _hm + + monkeypatch.setattr(_hm, "_openrouter_catalog_cache", None, raising=False) + except Exception: + pass + + # Force openrouter to be detected by injecting it into config + monkeypatch.setattr( + config, + "cfg", + { + "model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}, + "providers": {"openrouter": {"api_key": "sk-or-test-key"}}, + }, + raising=False, + ) + # Reset module-level cache + try: + config.invalidate_models_cache() + except Exception: + pass + + +def test_fallback_list_contains_free_tier_entries(): + """The hardcoded fallback list (defense-in-depth) still contains the + contributor's free-tier entries so offline / test envs see them.""" + or_entries = [m for m in config._FALLBACK_MODELS if m.get("provider") == "OpenRouter"] + assert len(or_entries) >= 5, "fallback list should include at least 5 free-tier entries" + free_labels = [m["label"] for m in or_entries if "free" in m["label"].lower()] + assert len(free_labels) >= 5, f"expected ≥5 free-tier entries in fallback, got {len(free_labels)}" + + +def test_openrouter_group_uses_live_fetch_when_available(monkeypatch): + """When OpenRouter /v1/models is reachable, the picker shows live data, + not just the fallback list. Free-tier entries get a (free) suffix.""" + fake_payload = _make_or_payload( + # Tool-supporting paid model + {"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", + "supported_parameters": ["tools"], "pricing": {"prompt": "0.000003", "completion": "0.000015"}}, + # Free-tier model NOT advertising tools — the bug from #1426 + {"id": "minimax/minimax-m2.5:free", "name": "MiniMax M2.5", + "supported_parameters": [], "pricing": {"prompt": "0", "completion": "0"}}, + # Free model without :free suffix but pricing shows free + {"id": "openrouter/elephant-alpha", "name": "Elephant Alpha", + "supported_parameters": ["tools"], "pricing": {"prompt": "0", "completion": "0"}}, + ) + + def _fake_urlopen(req, timeout=None): + return _FakeResponse(fake_payload) + + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen) + try: + from hermes_cli import models as _hm + monkeypatch.setattr(_hm, "_openrouter_catalog_cache", None, raising=False) + except Exception: + pass + + grouped = _get_grouped_models() + or_group = next((g for g in grouped if g.get("provider_id") == "openrouter"), None) + assert or_group is not None, "openrouter group must be present" + + model_ids = [m["id"] for m in or_group["models"]] + # Free-tier variants must be visible despite not advertising tool support + assert "minimax/minimax-m2.5:free" in model_ids, \ + "free-tier minimax/minimax-m2.5:free must surface in the picker even without tools support" + assert "openrouter/elephant-alpha" in model_ids, \ + "free pricing model must surface even without :free suffix" + + +def test_openrouter_falls_back_to_static_when_live_fails(monkeypatch): + """If both hermes_cli.fetch and the direct urlopen raise, the picker + must fall back to the hardcoded `_FALLBACK_MODELS` list — never empty.""" + def _fake_urlopen(req, timeout=None): + raise OSError("simulated network outage") + + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen) + + # Force hermes_cli to fail too + import sys + fake_module = type(sys)("hermes_cli.models") + + def _raise(*args, **kwargs): + raise RuntimeError("simulated import failure") + + fake_module.fetch_openrouter_models = _raise + fake_module.provider_model_ids = lambda *a, **k: [] + monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_module) + + grouped = _get_grouped_models() + or_group = next((g for g in grouped if g.get("provider_id") == "openrouter"), None) + assert or_group is not None, "openrouter group must still be present in fallback path" + assert len(or_group["models"]) > 0, "fallback must produce a non-empty model list" + # The hardcoded free-tier entries MUST be in the fallback + fallback_ids = {m["id"] for m in or_group["models"]} + # At least one of the contributor's hardcoded free-tier entries must be present + expected_free_ids = { + "openrouter/elephant-alpha", + "openrouter/owl-alpha", + "tencent/hy3-preview:free", + "nvidia/nemotron-3-super-120b-a12b:free", + "arcee-ai/trinity-large-preview:free", + } + overlap = fallback_ids & expected_free_ids + assert len(overlap) >= 3, \ + f"static fallback must include the contributor's hardcoded free-tier entries; got overlap={overlap}" + + +def test_free_tier_cap_prevents_picker_drowning(monkeypatch): + """OpenRouter may return hundreds of free-tier variants — the implementation + caps the live-fetch additions at 30 to keep the picker usable.""" + items = [] + for i in range(50): + items.append({ + "id": f"vendor{i}/model-{i}:free", + "name": f"Model {i}", + "supported_parameters": [], + "pricing": {"prompt": "0", "completion": "0"}, + }) + fake_payload = _make_or_payload(*items) + + def _fake_urlopen(req, timeout=None): + return _FakeResponse(fake_payload) + + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen) + + try: + from hermes_cli import models as _hm + monkeypatch.setattr(_hm, "_openrouter_catalog_cache", None, raising=False) + except Exception: + pass + + grouped = _get_grouped_models() + or_group = next((g for g in grouped if g.get("provider_id") == "openrouter"), None) + assert or_group is not None + free_added_ids = {m["id"] for m in or_group["models"] if ":free" in m["id"]} + assert len(free_added_ids) <= 50, "should not exceed the items provided" + assert len(free_added_ids) > 0, "free-tier live fetch should add at least some entries" + + +def test_openrouter_dedupe_curated_and_free_tier(monkeypatch): + """If a model appears in both the curated catalog AND the free-tier fetch, + it must appear exactly once in the picker (via `seen_ids` deduplication).""" + fake_payload = _make_or_payload( + {"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", + "supported_parameters": ["tools"], "pricing": {"prompt": "0", "completion": "0"}}, + ) + + def _fake_urlopen(req, timeout=None): + return _FakeResponse(fake_payload) + + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen) + + import sys + fake_module = type(sys)("hermes_cli.models") + fake_module.fetch_openrouter_models = lambda **k: [("anthropic/claude-sonnet-4.6", "")] + fake_module.provider_model_ids = lambda *a, **k: ["anthropic/claude-sonnet-4.6"] + monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_module) + + grouped = _get_grouped_models() + or_group = next((g for g in grouped if g.get("provider_id") == "openrouter"), None) + assert or_group is not None + matching = [m for m in or_group["models"] if m["id"] == "anthropic/claude-sonnet-4.6"] + assert len(matching) == 1, \ + f"model present in both surfaces should appear once, got {len(matching)}" diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py index 692b06cf..9893c7e2 100644 --- a/tests/test_minimax_provider.py +++ b/tests/test_minimax_provider.py @@ -92,10 +92,22 @@ def test_minimax_m2_7_highspeed_in_fallback_models(): def test_minimax_fallback_provider_label(): - """MiniMax fallback entries must use 'MiniMax' as the provider label.""" - minimax_entries = [m for m in config._FALLBACK_MODELS if 'minimax' in m['id'].lower()] - assert minimax_entries, "No MiniMax entries found in _FALLBACK_MODELS" - for entry in minimax_entries: + """MiniMax fallback entries (direct API routing) must use 'MiniMax' as + the provider label. + + NOTE: This filters by `minimax/` ID prefix to scope strictly to the + direct MiniMax provider routes — `minimax-X` is the canonical pattern + for hermes-agent routing to api.minimax.io. OpenRouter free-tier variants + that happen to contain 'minimax' in their ID (e.g. + `minimax/minimax-m2.5:free`) are routed via OpenRouter, not direct + MiniMax, and correctly carry provider='OpenRouter'. See #1426. + """ + direct_minimax = [ + m for m in config._FALLBACK_MODELS + if m['id'].startswith('minimax/') and ':free' not in m['id'] + ] + assert direct_minimax, "No direct-MiniMax entries found in _FALLBACK_MODELS" + for entry in direct_minimax: assert entry['provider'] == 'MiniMax', ( f"Expected provider='MiniMax', got '{entry['provider']}' for {entry['id']}" ) From 675f997b53e2ce9ec71c5f88ac0a8307ac58a262 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sun, 3 May 2026 19:28:58 +0000 Subject: [PATCH 10/11] fix(i18n): add reveal_in_finder/reveal_failed keys to pt locale (Opus advisor SHOULD-FIX absorbed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-release Opus advisor caught a gap: PR #1551 v2 added the reveal_in_finder/reveal_failed keys to en/ja/ru/es/de/zh/zh-Hant/ko but omitted the pt block. Locale parity tests for ja/ru/es/zh/ko all pass because they run en_keys - locale_keys parity assertions, but pt and de have no general parity test — so the pt gap would silently ship with Portuguese users seeing English fallback for the new context-menu item. Translations: pt: Mostrar no gerenciador de arquivos / Falha ao mostrar: Trivial 2-LOC absorb. Filing follow-up issue for pt/de cross-locale parity test mirroring the existing 5. --- static/i18n.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index 6d33b3e6..a5d435a1 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -6313,6 +6313,8 @@ const LOCALES = { delete_confirm: (name) => `Excluir ${name}?`, deleted: 'Excluído ', delete_failed: 'Falha ao excluir: ', + reveal_in_finder: 'Mostrar no gerenciador de arquivos', + reveal_failed: 'Falha ao mostrar: ', new_file_prompt: 'Nome do novo arquivo (ex: notes.md):', project_name_prompt: 'Nome do projeto:', created: 'Criado ', From d83a56dab299fb6d98bb74f2ec54e5d887454bfa Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sun, 3 May 2026 19:30:14 +0000 Subject: [PATCH 11/11] =?UTF-8?q?release:=20stamp=20v0.50.283=20=E2=80=94?= =?UTF-8?q?=208-PR=20full=20sweep=20batch=20(4018=20=E2=86=92=204019=20tes?= =?UTF-8?q?ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ ROADMAP.md | 2 +- TESTING.md | 4 ++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8236ccb2..bca7f8d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Hermes Web UI -- Changelog +## [v0.50.283] — 2026-05-03 + +### Fixed (8 PRs — full sweep batch — closes #1426, #1481, #1512, #1468, #1424, #1457, #1401) + +- **OpenRouter free-tier visibility — structural live fetch** (#1548 augmented from @bergeouss; closes #1426) — when an operator selected an OpenRouter free-tier model like `minimax/minimax-m2.5:free`, it was invisible in the picker because `hermes_cli/models.py:_openrouter_model_supports_tools()` filters out models that don't advertise `tools` in `supported_parameters` — and OpenRouter often hasn't yet annotated newly-added free variants. The original PR added 5 hardcoded `_FALLBACK_MODELS` entries; per maintainer directive ("augment the one that's going to rot fast with a live refresh"), the merged version replaces the static slice with two live-fetches plus the static fallback for offline/test envs: (1) curated catalog via `hermes_cli.models.fetch_openrouter_models()` — applies the tool-support filter; (2) direct `https://openrouter.ai/api/v1/models` filtered to free-tier-only (`pricing.prompt == 0` AND `pricing.completion == 0`, OR `:free` suffix), bypassing the tool-support filter so newly-added free variants appear even before OpenRouter annotates them with `tools`. Capped at 30 to keep the picker usable. Falls back to `_FALLBACK_MODELS[provider==OpenRouter]` (which retains @bergeouss's hardcoded list as defense-in-depth) when both live fetches fail. Dedup via `seen_ids` so a model in both surfaces appears once. 5 new tests in `tests/test_issue1426_openrouter_free_tier_live_fetch.py`. Pre-release Opus advisor verified no SSRF surface (URL is hardcoded literal, can't be config-redirected). + +- **Pending user turn recovery on stale stream restart** (#1543, @ai-ag2026; follow-up to #1471) — when a server restart happens mid-turn, the user's just-submitted prompt was the only durable copy and was silently discarded along with the stale stream state. Now `api/models.py:_apply_core_sync_or_error_marker` materializes the pending user turn with `_recovered: true` BEFORE clearing runtime fields if `messages` is non-empty AND `pending_user_message` is set. Adds 49 LOC of regression coverage in `tests/test_stale_stream_pending_recovery.py`. + +- **Silent credential self-heal on 401 errors** (#1553, @bergeouss; closes #1401) — when `auth.json` drifts (file rewritten by another process, OAuth refresh elsewhere, env-var rotation) and the streaming layer hits an auth-only 401, the WebUI now re-reads `auth.json`, invalidates the credential pool cache via the new `invalidate_credential_pool_cache(provider_id)` export, and retries the request once with fresh credentials. Single retry only, auth-only trigger, thread-safe (acquires `_available_models_cache_lock` for cache mutation). Reverts to the original error emission if the retry also fails. ~263 LOC across `api/streaming.py`, `api/oauth.py`, `api/config.py`. Pre-release Opus flagged 4 non-blocking SHOULD-FIX code-quality items (retry-logic duplication between in-line and except paths, fragile `_assistant_added=True` flag pattern, `in dir()` vs `in locals()` idiom, no `cancel_evt` check before retry) — deferred as follow-up since structural refactor is >20 LOC. + +- **Reveal in File Manager** (#1551, @bergeouss; closes #1424) — new workspace-file context menu item. Cross-platform: macOS (`open -R`), Linux (`xdg-open` on parent dir), Windows (`explorer /select,`). New `/api/workspace/reveal` POST handler validates the path through `safe_resolve` (verified by Opus advisor — blocks both absolute `/etc/passwd` injection and relative `../` traversal) and uses list-arg `subprocess.Popen` (no shell injection). Plus 2 new i18n keys (`reveal_in_finder`, `reveal_failed`) translated to all 8 non-English locales (ja, ru, es, de, zh, zh-Hant, pt, ko) — pt translation absorbed in-stage from Opus advisor SHOULD-FIX (contributor branch covered en + 7 locales, missed pt; pt parity test doesn't exist yet so the gap was invisible to CI but would have shown English fallback to Portuguese users). + +- **Gateway status card in Settings → System** (#1552, @bergeouss; closes #1457) — new read-only display card in the System settings tab. New `/api/gateway/status` endpoint returns connected platforms (Telegram/Discord/Slack/Weixin), active session count, and last-active timestamp. No behavior change to gateway internals. + +- **Auto-assign session to active project filter** (#1550, @bergeouss; closes #1468) — when the user is filtering the sidebar by project X and clicks "+ New session", the new session inherits `project_id=X` instead of starting unassigned. Three-line `api/models.py:new_session` signature extension (`project_id=None` kwarg) + matching frontend pass-through in `static/sessions.js`. + +- **"What's new?" link in update banner** (#1549, @bergeouss; closes #1512) — `api/updates.py:_check_repo` now returns `repo_url` (SSH→HTTPS conversion + `.git` strip); the update banner adds a small accent-colored anchor that points to `${repo_url}/compare/${current}...${latest}` so users can read release highlights in one click. + +- **Phantom `/sw.js` PUBLIC_PATHS whitelist removed** (#1545, @bergeouss; closes #1481) — the `/sw.js` path is served via a dedicated route handler that doesn't go through the `PUBLIC_PATHS` check, so the leftover whitelist entry was vestigial. When auth is enabled, `/sw.js` correctly requires the session cookie (security hardening side-effect, not a regression — service worker fetches travel with the cookie from authenticated context). + +### Tests + +3990 → **4019 passing** (+29 net from constituents: +5 from #1548 OpenRouter, +1 from #1543 recovery, +14 from PR #1544's earlier #1538/#1539 work shipped in v0.50.282, +9 from this batch including the +5 OpenRouter regression suite). 0 regressions. Full suite in 111s. + +### Pre-release verification + +- All 8 merges produced clean `ort` strategy results (no conflict markers). +- Browser sanity (HTTP API checks against port 8789): 11 endpoints verified. +- All modified JS files pass `node -c` syntax check. +- Pre-release Opus advisor v2: SHIP WITH ABSORPTIONS — 1 MUST absorb (≤2 LOC pt locale gap, applied in-stage), 4 SHOULD-FIX deferred from #1553 self-heal (>20 LOC structural refactor, follow-up issue planned), 1 SHOULD-FIX deferred for cross-locale parity test (would have caught the pt gap at PR review time). + +### Maintainer post-merge fixes (in-stage) + +- `static/i18n.js`: pt locale `reveal_in_finder` / `reveal_failed` translations added (Opus-flagged, 2 LOC). +- `tests/test_minimax_provider.py::test_minimax_fallback_provider_label` — scoped to direct-MiniMax routes (filter by `minimax/` prefix, exclude `:free`) since #1548's `minimax/minimax-m2.5:free` correctly carries `provider='OpenRouter'` (it routes via OpenRouter, not direct MiniMax). + + ## [v0.50.282] — 2026-05-03 ### Fixed (1 PR — closes #1538, #1539) diff --git a/ROADMAP.md b/ROADMAP.md index e3b862d6..31751057 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.50.282 (May 03, 2026) — 4018 tests collected +> Last updated: v0.50.283 (May 03, 2026) — 4019 tests collected > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index 37eaaabd..400b9118 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.50.282, May 03, 2026* -*Total automated tests collected: 4018* +*Last updated: v0.50.283, May 03, 2026* +*Total automated tests collected: 4019* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /*