# Conflicts:
#	CHANGELOG.md
#	tests/test_webui_gateway_chat_backend.py
This commit is contained in:
nesquena-hermes 2026-05-28 17:47:56 +00:00
commit a3fc305aeb
8 changed files with 182 additions and 10 deletions

View file

@ -45,6 +45,9 @@
- Local `MEDIA:` image tokens in chat history now include the current session id and can render exact image paths already present in that session transcript, so agent-generated artifacts outside the active workspace no longer show as broken thumbnails while arbitrary local paths remain blocked.
- Gateway-backed browser chat now turns Gateway API Server 401s into a specific `gateway_auth_error` explaining that `HERMES_WEBUI_GATEWAY_API_KEY` must match `API_SERVER_KEY`, instead of surfacing the Gateway's generic "Invalid API key" body as if the model provider key failed. The browser error renderer recognizes this event type as "Gateway authentication failed" instead of falling back to a generic "Error" heading. `/api/health/agent` also reports redacted gateway-chat configuration status (`enabled`, backend, base URL configured, API key configured) as an operator diagnostic payload; it is not currently rendered as a user-facing health banner.
### Documentation
- Clarify two Docker onboarding traps: `sudo docker compose` can mount `/root/.hermes` instead of the user's Hermes home on Linux, and Linux Docker Engine users should use a `host-gateway` alias such as `api.local` for host-local model servers instead of configuring `localhost` inside the container. (#3006, #3012)

View file

@ -180,7 +180,14 @@ HERMES_WEBUI_GATEWAY_API_KEY=... \
`api_server`, or `api-server` enable the bridge. Generic truthy values such as
`1` or `true` are ignored so existing deployments do not change execution
ownership accidentally. If `HERMES_WEBUI_GATEWAY_API_KEY` is omitted, WebUI falls
back to `API_SERVER_KEY` when present.
back to `API_SERVER_KEY` when present. When Gateway returns HTTP 401, WebUI
reports a `gateway_auth_error` that points at this WebUI↔Gateway key mismatch
rather than showing the Gateway's generic provider-style "Invalid API key" body.
`/api/health/agent` also includes a redacted `gateway_chat` block so operators can
see whether gateway mode, base URL, and API-key presence are configured without
exposing the key value. That `gateway_chat` field is an operator diagnostic
payload only; it is not currently rendered as a user-facing health banner in the
browser UI.
The bridge is best used by operators who already run Hermes Gateway/API Server
locally and want browser-originated chat to use the same runtime/tool path as

View file

@ -79,6 +79,40 @@ def _gateway_api_key(environ: dict[str, str] | None = None) -> str:
).strip()
def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict:
"""Return redacted Gateway-backed chat configuration status."""
mode = webui_chat_backend_mode(config_data, environ)
base_url = _gateway_base_url(config_data, environ)
return {
"enabled": mode == "gateway",
"backend": mode,
"base_url_configured": bool(base_url),
"api_key_configured": bool(_gateway_api_key(environ)),
}
def _gateway_http_error_event(exc: urllib.error.HTTPError, err_body: str, *, api_key_configured: bool) -> dict:
safe = _redact_text(err_body or str(exc))[:500]
if exc.code == 401:
return {
"label": "Gateway authentication failed",
"type": "gateway_auth_error",
"message": "Gateway rejected the WebUI API key (HTTP 401).",
"hint": (
"Set HERMES_WEBUI_GATEWAY_API_KEY to the same value as the Hermes Gateway "
"API_SERVER_KEY, or disable HERMES_WEBUI_CHAT_BACKEND=gateway."
if not api_key_configured
else "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
),
}
return {
"label": "Gateway request failed",
"type": "gateway_http_error",
"message": f"Gateway returned HTTP {exc.code}.",
"hint": safe or "Check the configured Gateway API server.",
}
def _gateway_sse_delta(payload: dict) -> str:
"""Extract assistant text from an OpenAI-compatible streaming chunk."""
try:
@ -296,13 +330,10 @@ def _run_gateway_chat_streaming(
err_body = exc.read(2048).decode("utf-8", errors="replace")
except Exception:
err_body = ""
safe = _redact_text(err_body or str(exc))[:500]
put_gateway_event("apperror", {
"label": "Gateway request failed",
"type": "gateway_http_error",
"message": f"Gateway returned HTTP {exc.code}.",
"hint": safe or "Check the configured Gateway API server.",
})
put_gateway_event(
"apperror",
_gateway_http_error_event(exc, err_body, api_key_configured=bool(_gateway_api_key())),
)
except Exception as exc:
safe = _redact_text(str(exc))[:500]
put_gateway_event("apperror", {

View file

@ -982,6 +982,7 @@ from api.helpers import (
_redact_text,
)
from api.agent_health import build_agent_health_payload
from api.gateway_chat import gateway_chat_config_status
from api.request_diagnostics import RequestDiagnostics
from api.system_health import build_system_health_payload
@ -3994,7 +3995,10 @@ def handle_get(handler, parsed) -> bool:
return _handle_health(handler, parsed)
if parsed.path == "/api/health/agent":
return j(handler, build_agent_health_payload())
payload = build_agent_health_payload()
payload["gateway_chat"] = gateway_chat_config_status()
j(handler, payload)
return True
if parsed.path == "/api/system/health":
j(handler, build_system_health_payload())

View file

@ -167,6 +167,7 @@ const LOCALES = {
model_unavailable_title: 'This model is no longer in your current provider list',
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
provider_mismatch_label: 'Provider mismatch',
gateway_auth_label: 'Gateway authentication failed',
model_not_found_label: 'Model not found',
model_custom_label: 'Custom model ID',
model_custom_placeholder: 'e.g. openai/gpt-5.4',
@ -1459,6 +1460,7 @@ const LOCALES = {
model_unavailable_title: 'Questo modello non è più nella lista provider attuale',
provider_mismatch_warning: (m,p)=>`"${m}" potrebbe non funzionare con il provider configurato (${p}). Invia comunque, o esegui \`hermes model\` nel terminale per cambiare.`,
provider_mismatch_label: 'Provider non corrispondente',
gateway_auth_label: 'Autenticazione Gateway non riuscita',
model_not_found_label: 'Modello non trovato',
model_custom_label: 'ID modello personalizzato',
model_custom_placeholder: 'es. openai/gpt-5.4',
@ -2743,6 +2745,7 @@ const LOCALES = {
model_unavailable_title: 'このモデルは現在のプロバイダ一覧に含まれていません',
provider_mismatch_warning: (m,p)=>`"${m}" は設定されたプロバイダ (${p}) で動作しない可能性があります。このまま送信するか、ターミナルで \`hermes model\` を実行して切り替えてください。`,
provider_mismatch_label: 'プロバイダ不一致',
gateway_auth_label: 'ゲートウェイ認証に失敗しました',
model_not_found_label: 'モデルが見つかりません',
model_custom_label: 'カスタムモデルID',
model_custom_placeholder: '例: openai/gpt-5.4',
@ -4011,6 +4014,7 @@ const LOCALES = {
provider_mismatch_warning: (m, p) =>
`"${m}" может не работать с вашим настроенным провайдером (${p}). Всё равно отправить или запустите \`hermes model\` в терминале, чтобы переключиться.`,
provider_mismatch_label: 'Несовпадение провайдера',
gateway_auth_label: 'Сбой аутентификации Gateway',
model_not_found_label: 'Модель не найдена',
model_custom_label: 'Пользовательский ID модели',
model_custom_placeholder: 'например, openai/gpt-5.4',
@ -5235,6 +5239,7 @@ const LOCALES = {
model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores',
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
provider_mismatch_label: 'Proveedor incompatible',
gateway_auth_label: 'Error de autenticación de Gateway',
model_not_found_label: 'Modelo no encontrado',
model_custom_label: 'ID de modelo personalizado',
model_custom_placeholder: 'p. ej. openai/gpt-5.4',
@ -6456,6 +6461,7 @@ const LOCALES = {
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
provider_mismatch_label: 'Provider-Konflikt',
gateway_auth_label: 'Gateway-Authentifizierung fehlgeschlagen',
model_not_found_label: 'Modell nicht gefunden',
// commands.js
cmd_help: 'Verfügbare Befehle auflisten',
@ -7681,6 +7687,7 @@ const LOCALES = {
model_unavailable_title: '这个模型已经不在当前 provider 列表中',
provider_mismatch_warning: (m,p)=>`\"${m}\" 可能无法在当前配置的提供商 (${p}) 下工作。直接发送,或在终端运行 \`hermes model\` 切换。`,
provider_mismatch_label: '提供商不匹配',
gateway_auth_label: 'Gateway 身份验证失败',
model_not_found_label: '未找到模型',
model_custom_label: '自定义模型 ID',
model_custom_placeholder: '例如 openai/gpt-5.4',
@ -8890,6 +8897,7 @@ const LOCALES = {
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
provider_mismatch_label: '\u63d0\u4f9b\u8005\u4e0d\u76f8\u7b26',
gateway_auth_label: 'Gateway 驗證失敗',
model_not_found_label: '\u672a\u627e\u5230\u6a21\u578b',
// commands.js
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
@ -10132,6 +10140,7 @@ const LOCALES = {
model_unavailable_title: 'Este modelo não está mais na sua lista de provedores',
provider_mismatch_warning: (m,p)=>`"${m}" pode não funcionar com seu provedor configurado (${p}). Enviar assim mesmo, ou execute \`hermes model\` no terminal para trocar.`,
provider_mismatch_label: 'Provedor incompatível',
gateway_auth_label: 'Falha na autenticação do Gateway',
model_not_found_label: 'Modelo não encontrado',
composer_mobile_workspace: 'Workspace',
composer_mobile_model: 'Modelo',
@ -11301,6 +11310,7 @@ const LOCALES = {
model_unavailable_title: 'This model is no longer in your current provider list',
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
provider_mismatch_label: 'Provider mismatch',
gateway_auth_label: 'Gateway 인증 실패',
model_not_found_label: 'Model not found',
model_custom_label: 'Custom model ID',
model_custom_placeholder: 'e.g. openai/gpt-5.4',
@ -12588,6 +12598,7 @@ const LOCALES = {
model_unavailable: '(indisponible)',
model_unavailable_title: 'Ce modèle ne figure plus dans votre liste de fournisseurs actuelle',
provider_mismatch_label: 'Inadéquation des fournisseurs',
gateway_auth_label: 'Échec de l’authentification Gateway',
model_not_found_label: 'Modèle introuvable',
model_custom_label: 'ID de modèle personnalisé',
model_custom_placeholder: 'par ex. openai/gpt-5.4',
@ -13806,6 +13817,7 @@ const LOCALES = {
model_unavailable_title: 'Bu model artık mevcut sağlayıcı listenizde değil',
provider_mismatch_warning: (m,p) => `"${m}" yapılandırılmış sağlayıcınızla (${p}) çalışmayabilir. Yine de gönderin veya geçiş yapmak için terminalinizde \`hermes model\` komutunu çalıştırın.`,
provider_mismatch_label: 'Sağlayıcı uyumsuzluğu',
gateway_auth_label: 'Gateway kimlik doğrulaması başarısız',
model_not_found_label: 'Model bulunamadı',
model_custom_label: 'Özel model kimliği',
model_custom_placeholder: 'örneğin openai/gpt-5.4',

View file

@ -2107,11 +2107,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const isRateLimit=d.type==='rate_limit';
const isQuotaExhausted=d.type==='quota_exhausted';
const isAuthMismatch=d.type==='auth_mismatch';
const isGatewayAuthError=d.type==='gateway_auth_error';
const isModelNotFound=d.type==='model_not_found';
const isCancelled=d.type==='cancelled';
const isInterrupted=d.type==='interrupted';
const isNoResponse=d.type==='no_response'||d.type==='silent_failure';
const label=isCancelled?'Task cancelled':isInterrupted?'Response interrupted':isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response from provider':'Error';
const label=isCancelled?'Task cancelled':isInterrupted?'Response interrupted':isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isGatewayAuthError?(typeof t==='function'?t('gateway_auth_label'):'Gateway authentication failed'):isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response from provider':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
const details=d.details?String(d.details).replace(/```/g,'`\u200b``'):'';
const detailsLabel=isCancelled?'Cancellation details':isInterrupted?'Interruption details':undefined;

View file

@ -166,6 +166,8 @@ def test_agent_health_payload_unknown_when_gateway_is_not_configured(monkeypatch
def test_agent_health_route_is_registered_with_tri_state_payload_shape():
assert 'parsed.path == "/api/health/agent"' in ROUTES_PY
assert "build_agent_health_payload()" in ROUTES_PY
assert "gateway_chat_config_status()" in ROUTES_PY
assert 'payload["gateway_chat"]' in ROUTES_PY
src = (REPO_ROOT / "api" / "agent_health.py").read_text(encoding="utf-8")
assert '"alive"' in src
assert '"checked_at"' in src

View file

@ -1,14 +1,20 @@
from collections import OrderedDict
import base64
from email.message import Message
import json
from pathlib import Path
import re
import urllib.error
import api.gateway_chat as gateway_chat
import api.models as models
from api.config import STREAMS, create_stream_channel
from api.models import new_session
from api.gateway_chat import (
_gateway_http_error_event,
_gateway_sse_delta,
_gateway_stream_usage,
gateway_chat_config_status,
webui_chat_backend_mode,
webui_gateway_chat_enabled,
)
@ -33,6 +39,36 @@ def test_gateway_chat_backend_can_be_enabled_from_config_without_env():
assert webui_chat_backend_mode({"webui_chat_backend": "api_server"}, {}) == "gateway"
def test_gateway_chat_config_status_is_redacted_and_reports_missing_key():
status = gateway_chat_config_status(
{},
{
"HERMES_WEBUI_CHAT_BACKEND": "gateway",
"HERMES_WEBUI_GATEWAY_BASE_URL": "http://gateway.local",
},
)
assert status == {
"enabled": True,
"backend": "gateway",
"base_url_configured": True,
"api_key_configured": False,
}
def test_gateway_chat_config_status_reports_fallback_api_server_key_without_exposing_value():
status = gateway_chat_config_status(
{},
{
"HERMES_WEBUI_CHAT_BACKEND": "gateway",
"API_SERVER_KEY": "secret-token",
},
)
assert status["api_key_configured"] is True
assert "secret-token" not in repr(status)
def test_gateway_chat_backend_env_wins_over_config_and_stays_safe():
assert webui_chat_backend_mode(
{"webui_chat_backend": "gateway"},
@ -60,6 +96,82 @@ def test_gateway_stream_usage_normalizes_token_names():
assert _gateway_stream_usage({}) == {}
def test_gateway_http_401_reports_gateway_auth_not_provider_key():
exc = urllib.error.HTTPError(
"http://gateway.local/v1/chat/completions",
401,
"Unauthorized",
hdrs=Message(),
fp=None,
)
event = _gateway_http_error_event(
exc,
'{"error":{"message":"Invalid API key","code":"invalid_api_key"}}',
api_key_configured=False,
)
assert event["label"] == "Gateway authentication failed"
assert event["type"] == "gateway_auth_error"
assert "HTTP 401" in event["message"]
assert "HERMES_WEBUI_GATEWAY_API_KEY" in event["hint"]
assert "API_SERVER_KEY" in event["hint"]
assert "Invalid API key" not in event["hint"]
def test_gateway_http_401_with_key_suggests_key_mismatch():
exc = urllib.error.HTTPError(
"http://gateway.local/v1/chat/completions",
401,
"Unauthorized",
hdrs=Message(),
fp=None,
)
event = _gateway_http_error_event(exc, "", api_key_configured=True)
assert event["type"] == "gateway_auth_error"
assert event["hint"] == "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
def test_frontend_renders_gateway_auth_error_with_specific_label():
src = Path("static/messages.js").read_text(encoding="utf-8")
start = src.find("source.addEventListener('apperror'")
end = src.find("source.addEventListener('warning'", start)
assert start != -1 and end != -1, "apperror handler not found"
block = src[start:end]
assert "d.type==='gateway_auth_error'" in block
assert "isGatewayAuthError" in block
assert "gateway_auth_label" in block
assert "Gateway authentication failed" in block
assert "isGatewayAuthError?(typeof t==='function'?t('gateway_auth_label'):'Gateway authentication failed'):isAuthMismatch" in block, (
"Gateway API key failures should use their own label before generic provider mismatch handling."
)
def test_gateway_auth_label_i18n_key_exists_for_every_locale():
src = Path("static/i18n.js").read_text(encoding="utf-8")
locale_names = [
match.group("quoted") or match.group("plain")
for match in re.finditer(
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
src,
re.MULTILINE,
)
]
assert src.count("gateway_auth_label") >= len(locale_names)
def test_gateway_chat_health_payload_is_documented_as_operator_diagnostic_only():
readme = Path("README.md").read_text(encoding="utf-8")
changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
for text in (readme, changelog):
assert "gateway_chat" in text
assert "operator diagnostic" in text
assert "not currently rendered as a user-facing health banner" in text
def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()