Merge pull request #1556 from nesquena/stage-283

v0.50.283 — full PR sweep (8 PRs, 7 issues closed)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-03 12:32:55 -07:00
commit 47ba95fa92
19 files changed
+890 -69

No files matched your search

+37
View File
@@ -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,<path>`). 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)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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: <repo>/*
-1
View File
@@ -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'
+104 -4
View File
@@ -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
@@ -1444,6 +1452,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.
@@ -2109,14 +2129,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":
+20 -8
View File
@@ -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
@@ -713,7 +724,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 +760,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
+9
View File
@@ -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.
+71
View File
@@ -10,7 +10,9 @@ import logging
import os
import queue
import re
import platform
import shutil
import subprocess
import sys
import threading
import time
@@ -2181,6 +2183,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)
@@ -2253,6 +2292,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}})
@@ -2805,6 +2845,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 +5197,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()
+242 -24
View File
@@ -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. '
+9
View File
@@ -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,
}
+18
View File
@@ -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',
@@ -6299,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 ',
@@ -7089,6 +7105,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: '생성됨: ',
+7
View File
@@ -260,6 +260,7 @@
<div class="update-banner" id="updateBanner">
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
<span id="updateMsg"></span>
<a id="updateWhatsNew" href="#" target="_blank" rel="noopener" style="font-size:11px;color:var(--accent);text-decoration:underline;display:none;margin-left:8px;white-space:nowrap">What's new?</a>
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
@@ -931,6 +932,12 @@
</div>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
<!-- Gateway Status Section -->
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
<label>Gateway Status</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Status of the Hermes gateway (Telegram, Discord, Slack, etc.)</div>
<div id="gatewayStatusCard"><span style="color:var(--muted);font-size:12px">Loading…</span></div>
</div>
<!-- MCP Servers Section -->
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
<label data-i18n="mcp_servers_title">MCP Servers</label>
+23 -1
View File
@@ -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=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>Gateway not running</div>`;
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 `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;background:var(--code-bg);border:1px solid var(--border2);border-radius:12px;font-size:12px;font-weight:500">${icon} ${esc(p.label)}</span>`;
}).join(' ');
}
const lastActive=r.last_active?`<span style="font-size:11px;color:var(--muted)">Last active: ${esc(new Date(r.last_active).toLocaleString())}</span>`:'';
const sessionInfo=r.session_count?`<span style="font-size:11px;color:var(--muted)">${r.session_count} session${r.session_count!==1?'s':''}</span>`:'';
card.innerHTML=`<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block"></span><span style="font-size:13px;font-weight:500;color:#22c55e">Running</span></div>${badges?`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${badges}</div>`:''}<div style="display:flex;gap:12px">${sessionInfo}${lastActive}</div>`;
}).catch(()=>{card.innerHTML=`<div style="color:#ef4444;font-size:12px">Failed to load gateway status</div>`});
}
// 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 ──────────────────────────────────────────────────
+4 -2
View File
@@ -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;
+20
View File
@@ -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');
@@ -5318,6 +5329,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;';
@@ -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)}"
+16 -4
View File
@@ -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']}"
)
+32 -22
View File
@@ -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."""
@@ -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
)