Merge pull request #4371 from nesquena/stage-4364
Release v0.51.477 — QL (gateway runs-API routing now opt-in #4362)
This commit is contained in:
4 files changed
+104
-3
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.477] — 2026-06-17 — Release QL (gateway runs-API routing now opt-in)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gateway chat no longer silently loses mid-conversation context (#4362).** When the WebUI was connected to a Hermes gateway that advertised approval/runs support, it routed *every* browser chat turn through the gateway's `/v1/runs` API, which creates an isolated Api_Server session per turn — so the agent lost the conversation's context between messages. Routing through the runs API is now gated behind an explicit opt-in (`HERMES_WEBUI_GATEWAY_USE_RUNS_API=1` env var, or `webui_gateway_use_runs_api: true` in config), defaulting to off. Normal gateway chat now stays on `/v1/chat/completions`, which preserves the session via `X-Hermes-Session-Id`. Operators who want the in-gateway-chat approval-events flow can re-enable it with the flag. Thanks @rodboev.
|
||||
|
||||
## [v0.51.476] — 2026-06-17 — Release QK (cross-provider model-pick + non-git update-status fixes)
|
||||
|
||||
### Fixed
|
||||
|
||||
+14
-1
@@ -36,6 +36,7 @@ _STREAM_RUN_IDS: dict[str, str] = {}
|
||||
_WEBUI_CHAT_BACKEND_ENV = "HERMES_WEBUI_CHAT_BACKEND"
|
||||
_WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL"
|
||||
_WEBUI_GATEWAY_API_KEY_ENV = "HERMES_WEBUI_GATEWAY_API_KEY"
|
||||
_WEBUI_GATEWAY_USE_RUNS_API_ENV = "HERMES_WEBUI_GATEWAY_USE_RUNS_API"
|
||||
_GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
|
||||
|
||||
|
||||
@@ -83,6 +84,18 @@ def _gateway_api_key(environ: dict[str, str] | None = None) -> str:
|
||||
).strip()
|
||||
|
||||
|
||||
def _gateway_use_runs_api_enabled(config_data=None, environ: dict[str, str] | None = None) -> bool:
|
||||
"""Return True only when the operator has explicitly opted into the runs API path."""
|
||||
source = os.environ if environ is None else environ
|
||||
cfg = config_data if isinstance(config_data, dict) else {}
|
||||
raw = str(
|
||||
source.get(_WEBUI_GATEWAY_USE_RUNS_API_ENV)
|
||||
or cfg.get("webui_gateway_use_runs_api")
|
||||
or ""
|
||||
).strip().lower()
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
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)
|
||||
@@ -545,7 +558,7 @@ def _run_gateway_chat_streaming(
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
# Capability gate: use runs API when gateway advertises approval support.
|
||||
_use_runs_api = gateway_supports_approval(base_url, api_key)
|
||||
_use_runs_api = _gateway_use_runs_api_enabled(cfg) and gateway_supports_approval(base_url, api_key)
|
||||
if _use_runs_api:
|
||||
body_extras = {}
|
||||
if model_provider:
|
||||
|
||||
@@ -151,7 +151,7 @@ def test_gateway_runs_api_submission():
|
||||
mock_session.pending_started_at = None
|
||||
|
||||
try:
|
||||
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
|
||||
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway", "HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1"}):
|
||||
with patch("api.gateway_chat.gateway_supports_approval", lambda *_args, **_kwargs: True), \
|
||||
patch("api.gateway_chat._run_gateway_runs_api_streaming", fake_runs_streaming), \
|
||||
patch("api.gateway_chat.get_session", return_value=mock_session), \
|
||||
@@ -415,7 +415,7 @@ def test_gateway_runs_api_cancel_does_not_emit_empty_response():
|
||||
return None, {}
|
||||
|
||||
try:
|
||||
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
|
||||
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway", "HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1"}):
|
||||
with patch("api.gateway_chat.gateway_supports_approval", return_value=True), \
|
||||
patch("api.gateway_chat._run_gateway_runs_api_streaming", side_effect=fake_runs_streaming), \
|
||||
patch("api.gateway_chat.get_session", return_value=mock_session):
|
||||
|
||||
@@ -18,6 +18,7 @@ from api.gateway_chat import (
|
||||
_gateway_sse_reasoning_delta,
|
||||
_gateway_stream_usage,
|
||||
_gateway_tool_progress_event,
|
||||
_gateway_use_runs_api_enabled,
|
||||
gateway_chat_config_status,
|
||||
webui_chat_backend_mode,
|
||||
webui_gateway_chat_enabled,
|
||||
@@ -824,3 +825,84 @@ def test_gateway_chat_worker_forwards_image_attachments_as_multimodal_parts(tmp_
|
||||
assert content[0] == {"type": "text", "text": "What is in this image?"}
|
||||
assert content[1]["type"] == "image_url"
|
||||
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_gateway_use_runs_api_is_default_off():
|
||||
for env in ({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": ""}):
|
||||
assert _gateway_use_runs_api_enabled({}, env) is False
|
||||
|
||||
|
||||
def test_gateway_use_runs_api_only_accepts_explicit_truthy_values():
|
||||
for value in ("1", "true", "yes", "on", " True ", " ON "):
|
||||
assert _gateway_use_runs_api_enabled({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": value}) is True
|
||||
|
||||
|
||||
def test_gateway_use_runs_api_rejects_generic_truthy_strings():
|
||||
for value in ("enabled", "gateway", "api_server", "absolutely"):
|
||||
assert _gateway_use_runs_api_enabled({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": value}) is False
|
||||
|
||||
|
||||
def test_gateway_use_runs_api_can_be_enabled_from_config():
|
||||
assert _gateway_use_runs_api_enabled({"webui_gateway_use_runs_api": "true"}, {}) is True
|
||||
assert _gateway_use_runs_api_enabled({"webui_gateway_use_runs_api": "1"}, {}) is True
|
||||
|
||||
|
||||
def test_gateway_use_runs_api_env_wins_over_config():
|
||||
assert _gateway_use_runs_api_enabled(
|
||||
{"webui_gateway_use_runs_api": "true"},
|
||||
{"HERMES_WEBUI_GATEWAY_USE_RUNS_API": "false"},
|
||||
) is False
|
||||
|
||||
|
||||
def test_gateway_worker_skips_runs_api_when_opt_in_absent():
|
||||
"""Worker uses chat/completions even when gateway advertises approval support, unless opt-in is set."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from api.config import STREAMS, STREAMS_LOCK
|
||||
from api.gateway_chat import _run_gateway_chat_streaming
|
||||
|
||||
events = []
|
||||
q = MagicMock()
|
||||
q.put_nowait = lambda item: events.append(item)
|
||||
stream_id = "sid-optin-gate"
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = q
|
||||
|
||||
sse_body = b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'
|
||||
|
||||
def fake_urlopen(req, *, timeout=None):
|
||||
assert "/v1/chat/completions" in req.full_url
|
||||
resp = MagicMock()
|
||||
resp.__iter__ = lambda s: iter(sse_body.split(b"\n"))
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = lambda s, *a: None
|
||||
return resp
|
||||
|
||||
import os
|
||||
env_override = {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}
|
||||
env_without_opt_in = {
|
||||
k: v for k, v in os.environ.items()
|
||||
if k != "HERMES_WEBUI_GATEWAY_USE_RUNS_API"
|
||||
}
|
||||
env_without_opt_in.update(env_override)
|
||||
|
||||
try:
|
||||
with patch.dict("os.environ", env_without_opt_in, clear=True):
|
||||
with patch("api.gateway_chat.gateway_supports_approval", return_value=True), \
|
||||
patch("urllib.request.urlopen", side_effect=fake_urlopen), \
|
||||
patch("api.gateway_chat.get_session", return_value=MagicMock(
|
||||
active_stream_id=stream_id, workspace="/tmp",
|
||||
profile=None, context_messages=[], messages=[],
|
||||
)):
|
||||
_run_gateway_chat_streaming(
|
||||
session_id="sess-optin",
|
||||
msg_text="hi",
|
||||
model="test",
|
||||
workspace="/tmp",
|
||||
stream_id=stream_id,
|
||||
)
|
||||
event_types = [e[0] for e in events if isinstance(e, tuple) and len(e) >= 2]
|
||||
assert "token" in event_types, "expected a token event from chat/completions path"
|
||||
assert "apperror" not in event_types, "runs API path fired unexpectedly"
|
||||
finally:
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.pop(stream_id, None)
|
||||
Reference in new issue
Block a user