Merge PR #3077
# Conflicts: # CHANGELOG.md # tests/test_webui_gateway_chat_backend.py
This commit is contained in:
commit
a3fc305aeb
8 changed files with 182 additions and 10 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue