Merge remote-tracking branch 'origin/master' into webui-context-prefill-status
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
571bb1010d
16 files changed
+782
-20
No files matched your search
@@ -7,6 +7,23 @@
|
||||
|
||||
- The third-party notes drawer's “Recently used by AI” Joplin list now follows the WebUI-specific `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]`, before falling back to the legacy generic `prefill_messages_script`.
|
||||
|
||||
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
|
||||
|
||||
### Added
|
||||
|
||||
- Browser chat can now opt into a default-off `HERMES_WEBUI_CHAT_BACKEND=gateway` bridge that routes new WebUI turns through a running Hermes Gateway API server while preserving the existing WebUI chat start/stream contract. Strict enable: only the literal values `gateway`, `api_server`, or `api-server` activate the bridge — generic truthy strings like `1` or `true` keep the legacy in-process WebUI runtime. Configurable via `HERMES_WEBUI_GATEWAY_BASE_URL` (default `http://127.0.0.1:8642`) and `HERMES_WEBUI_GATEWAY_API_KEY` (falls back to `API_SERVER_KEY`). New `api/gateway_chat.py` module isolates the bridge logic; existing direct WebUI chat path unchanged when the env/config is not set. (#3021)
|
||||
|
||||
## [v0.51.151] — 2026-05-28 — Release DW (stage-batch33 — 3-PR mid-risk batch: SSE reattach + title-lang + composer cap)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Live SSE stream now reattaches when returning to a session that lost its connection during a session switch, closing the connection-leak window where stale `EventSource`s could accumulate. Also fixes a `_dirty_suffix` correctness path and yields the GIL after every SSE put so the HTTP server stays responsive under burst load. (#2924, #2925)
|
||||
- Generated session titles now stay in the conversation language by adding an explicit title-generation instruction to the auxiliary prompt. Prevents the default prompt from drifting into English for non-English conversations. (#2984)
|
||||
|
||||
### Changed
|
||||
|
||||
- Composer box max-width is now capped at 1600px on ultrawide viewports (≥1600px) so chips stay anchored against a content-sized boundary instead of stretching across 3440px+ displays. Maintainer-confirmed cap from the #2856 thread. (#2946)
|
||||
|
||||
## [v0.51.150] — 2026-05-28 — Release DV (stage-batch32 — single-PR reasoning-effort agent metadata)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -162,6 +162,32 @@ at 256 KiB before parsing. The browser only receives a compact status event
|
||||
(`source`, `label`, message count, and redacted errors), never the prefill
|
||||
message bodies.
|
||||
|
||||
### Optional Gateway-backed browser chat
|
||||
|
||||
By default, browser chat runs through WebUI's in-process legacy runtime. Advanced
|
||||
self-hosted deployments can opt into routing new browser turns through a running
|
||||
Hermes Gateway API server while preserving the existing WebUI `/api/chat/start`
|
||||
and `/api/chat/stream` browser contract:
|
||||
|
||||
```bash
|
||||
HERMES_WEBUI_CHAT_BACKEND=gateway \
|
||||
HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:8642 \
|
||||
HERMES_WEBUI_GATEWAY_API_KEY=... \
|
||||
./ctl.sh restart
|
||||
```
|
||||
|
||||
`HERMES_WEBUI_CHAT_BACKEND` is intentionally strict: only `gateway`,
|
||||
`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.
|
||||
|
||||
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
|
||||
messaging surfaces. Attachments, cancellation, approvals, and clarify prompts
|
||||
still follow WebUI's current compatibility path and may not match every messaging
|
||||
surface until the runtime-adapter migration is complete.
|
||||
|
||||
The bootstrap will:
|
||||
|
||||
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Default-off Hermes Gateway bridge for browser-originated chat turns."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from api.config import (
|
||||
CANCEL_FLAGS,
|
||||
STREAMS,
|
||||
STREAMS_LOCK,
|
||||
STREAM_LAST_EVENT_ID,
|
||||
STREAM_LIVE_TOOL_CALLS,
|
||||
STREAM_PARTIAL_TEXT,
|
||||
STREAM_REASONING_TEXT,
|
||||
_get_session_agent_lock,
|
||||
register_active_run,
|
||||
unregister_active_run,
|
||||
update_active_run,
|
||||
)
|
||||
from api.helpers import _redact_text, redact_session_data
|
||||
from api.models import get_session
|
||||
from api.run_journal import RunJournalWriter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_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"
|
||||
_GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
|
||||
|
||||
|
||||
def webui_chat_backend_mode(config_data=None, environ: dict[str, str] | None = None) -> str:
|
||||
"""Return the explicitly selected browser chat backend.
|
||||
|
||||
The default remains the in-process WebUI runtime. Only explicit gateway
|
||||
values opt browser chat into the Hermes API server bridge; generic truthy
|
||||
strings are deliberately ignored so deployments do not change execution
|
||||
ownership by accident.
|
||||
"""
|
||||
source = os.environ if environ is None else environ
|
||||
cfg = config_data if isinstance(config_data, dict) else {}
|
||||
raw = str(
|
||||
source.get(_WEBUI_CHAT_BACKEND_ENV)
|
||||
or cfg.get("webui_chat_backend")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if raw in _GATEWAY_CHAT_BACKENDS:
|
||||
return "gateway"
|
||||
return "legacy"
|
||||
|
||||
|
||||
def webui_gateway_chat_enabled(config_data=None, environ: dict[str, str] | None = None) -> bool:
|
||||
return webui_chat_backend_mode(config_data, environ) == "gateway"
|
||||
|
||||
|
||||
def _gateway_base_url(config_data=None, environ: dict[str, str] | None = None) -> str:
|
||||
source = os.environ if environ is None else environ
|
||||
cfg = config_data if isinstance(config_data, dict) else {}
|
||||
raw = str(
|
||||
source.get(_WEBUI_GATEWAY_BASE_URL_ENV)
|
||||
or cfg.get("webui_gateway_base_url")
|
||||
or "http://127.0.0.1:8642"
|
||||
).strip()
|
||||
return raw.rstrip("/") or "http://127.0.0.1:8642"
|
||||
|
||||
|
||||
def _gateway_api_key(environ: dict[str, str] | None = None) -> str:
|
||||
source = os.environ if environ is None else environ
|
||||
return str(
|
||||
source.get(_WEBUI_GATEWAY_API_KEY_ENV)
|
||||
or source.get("API_SERVER_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _gateway_sse_delta(payload: dict) -> str:
|
||||
"""Extract assistant text from an OpenAI-compatible streaming chunk."""
|
||||
try:
|
||||
choices = payload.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
choice = choices[0] or {}
|
||||
delta = choice.get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
message = choice.get("message") or {}
|
||||
content = message.get("content")
|
||||
return content if isinstance(content, str) else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _gateway_stream_usage(payload: dict) -> dict:
|
||||
usage = payload.get("usage") if isinstance(payload, dict) else None
|
||||
if not isinstance(usage, dict):
|
||||
return {}
|
||||
return {
|
||||
"input_tokens": int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0),
|
||||
"output_tokens": int(usage.get("completion_tokens") or usage.get("output_tokens") or 0),
|
||||
"estimated_cost": usage.get("estimated_cost") or usage.get("estimated_cost_usd") or 0,
|
||||
}
|
||||
|
||||
|
||||
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
|
||||
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
|
||||
|
||||
|
||||
def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
|
||||
if not _stream_writeback_is_current(session, stream_id):
|
||||
return
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = None
|
||||
session.pending_started_at = None
|
||||
session.save()
|
||||
|
||||
|
||||
def _run_gateway_chat_streaming(
|
||||
session_id,
|
||||
msg_text,
|
||||
model,
|
||||
workspace,
|
||||
stream_id,
|
||||
attachments=None,
|
||||
*,
|
||||
model_provider=None,
|
||||
):
|
||||
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
|
||||
|
||||
This default-off path keeps the browser contract unchanged: /api/chat/start
|
||||
still returns a local stream_id and /api/chat/stream still receives WebUI SSE
|
||||
event names. The worker translates OpenAI-compatible streaming chunks from
|
||||
the configured Gateway API server into those local events and persists the
|
||||
final user/assistant turn back into the WebUI session.
|
||||
"""
|
||||
q = STREAMS.get(stream_id)
|
||||
if q is None:
|
||||
return
|
||||
register_active_run(
|
||||
stream_id,
|
||||
session_id=session_id,
|
||||
started_at=time.time(),
|
||||
phase="gateway-starting",
|
||||
workspace=str(workspace),
|
||||
model=model,
|
||||
provider=model_provider,
|
||||
backend="gateway",
|
||||
)
|
||||
try:
|
||||
run_journal = RunJournalWriter(session_id, stream_id)
|
||||
except Exception:
|
||||
run_journal = None
|
||||
logger.debug("Failed to initialize gateway run journal for stream %s", stream_id, exc_info=True)
|
||||
cancel_event = threading.Event()
|
||||
with STREAMS_LOCK:
|
||||
CANCEL_FLAGS[stream_id] = cancel_event
|
||||
STREAM_PARTIAL_TEXT[stream_id] = ""
|
||||
STREAM_REASONING_TEXT[stream_id] = ""
|
||||
STREAM_LIVE_TOOL_CALLS[stream_id] = []
|
||||
|
||||
def put_gateway_event(event, data):
|
||||
if cancel_event.is_set() and event not in ("cancel", "error", "apperror"):
|
||||
return
|
||||
if run_journal is not None:
|
||||
try:
|
||||
journaled = run_journal.append_sse_event(event, data)
|
||||
event_id = (journaled or {}).get("event_id") if isinstance(journaled, dict) else None
|
||||
if event_id:
|
||||
STREAM_LAST_EVENT_ID[stream_id] = event_id
|
||||
except Exception:
|
||||
logger.debug("Failed to append gateway event %s for stream %s", event, stream_id, exc_info=True)
|
||||
try:
|
||||
q.put_nowait((event, data))
|
||||
except Exception:
|
||||
logger.debug("Failed to put gateway event to queue")
|
||||
|
||||
s = None
|
||||
final_text = ""
|
||||
usage = {"input_tokens": 0, "output_tokens": 0, "estimated_cost": 0}
|
||||
try:
|
||||
s = get_session(session_id)
|
||||
from api.config import get_config # imported lazily to avoid config-cycle churn
|
||||
|
||||
cfg = get_config()
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
url = f"{base_url}/v1/chat/completions"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
"X-Hermes-Session-Id": session_id,
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
# Scope Gateway long-term continuity to this WebUI conversation
|
||||
# without exposing the browser's auth cookie or CSRF material.
|
||||
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
||||
body = {
|
||||
"model": model or "default",
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": str(msg_text or "")}],
|
||||
}
|
||||
if model_provider:
|
||||
body["provider"] = model_provider
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
update_active_run(stream_id, phase="gateway-request")
|
||||
last_payload = {}
|
||||
with urllib.request.urlopen(req, timeout=600) as resp:
|
||||
for raw_line in resp:
|
||||
if cancel_event.is_set():
|
||||
put_gateway_event("cancel", {"message": "Cancelled by user"})
|
||||
return
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
last_payload = payload
|
||||
delta = _gateway_sse_delta(payload)
|
||||
if delta:
|
||||
final_text += delta
|
||||
if stream_id in STREAM_PARTIAL_TEXT:
|
||||
STREAM_PARTIAL_TEXT[stream_id] += delta
|
||||
put_gateway_event("token", {"text": delta})
|
||||
usage.update({k: v for k, v in _gateway_stream_usage(payload).items() if v})
|
||||
usage.update({k: v for k, v in _gateway_stream_usage(last_payload).items() if v})
|
||||
assistant_text = final_text.strip()
|
||||
if not assistant_text:
|
||||
put_gateway_event("apperror", {
|
||||
"label": "Gateway returned no response",
|
||||
"type": "gateway_empty_response",
|
||||
"message": "Gateway returned no assistant message for this turn.",
|
||||
"hint": "Check that Hermes Gateway API server is running and reachable.",
|
||||
})
|
||||
return
|
||||
with _get_session_agent_lock(session_id):
|
||||
s = get_session(session_id)
|
||||
if not _stream_writeback_is_current(s, stream_id):
|
||||
return
|
||||
now = int(time.time())
|
||||
user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now}
|
||||
if attachments:
|
||||
user_msg["attachments"] = list(attachments)
|
||||
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": now}
|
||||
previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or [])
|
||||
s.context_messages = previous_context + [user_msg, assistant_msg]
|
||||
display = list(getattr(s, "messages", None) or [])
|
||||
# Avoid duplicating the eager-save checkpointed user message.
|
||||
if display:
|
||||
latest = display[-1]
|
||||
if isinstance(latest, dict) and latest.get("role") == "user":
|
||||
latest_text = " ".join(str(latest.get("content") or "").split())
|
||||
msg_norm = " ".join(str(msg_text or "").split())
|
||||
if latest_text == msg_norm:
|
||||
display = display[:-1]
|
||||
s.messages = display + [user_msg, assistant_msg]
|
||||
s.active_stream_id = None
|
||||
s.pending_user_message = None
|
||||
s.pending_attachments = None
|
||||
s.pending_started_at = None
|
||||
s.workspace = str(workspace)
|
||||
s.model = model
|
||||
s.model_provider = model_provider
|
||||
s.save()
|
||||
gateway_session_payload = s.compact() | {"messages": s.messages, "tool_calls": []}
|
||||
put_gateway_event("done", {"session": redact_session_data(gateway_session_payload), "usage": usage})
|
||||
put_gateway_event("stream_end", {"session_id": session_id})
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
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.",
|
||||
})
|
||||
except Exception as exc:
|
||||
safe = _redact_text(str(exc))[:500]
|
||||
put_gateway_event("apperror", {
|
||||
"label": "Gateway request failed",
|
||||
"type": "gateway_error",
|
||||
"message": safe or "Gateway request failed.",
|
||||
"hint": "Check HERMES_WEBUI_GATEWAY_BASE_URL and Gateway API server health.",
|
||||
})
|
||||
finally:
|
||||
if s is not None:
|
||||
try:
|
||||
with _get_session_agent_lock(session_id):
|
||||
_clear_gateway_pending_state(get_session(session_id), stream_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to clear gateway stream state", exc_info=True)
|
||||
with STREAMS_LOCK:
|
||||
CANCEL_FLAGS.pop(stream_id, None)
|
||||
STREAM_PARTIAL_TEXT.pop(stream_id, None)
|
||||
STREAM_REASONING_TEXT.pop(stream_id, None)
|
||||
STREAM_LIVE_TOOL_CALLS.pop(stream_id, None)
|
||||
STREAM_LAST_EVENT_ID.pop(stream_id, None)
|
||||
STREAMS.pop(stream_id, None)
|
||||
unregister_active_run(stream_id)
|
||||
+8
-2
@@ -2579,6 +2579,7 @@ from api.streaming import (
|
||||
cancel_stream,
|
||||
_materialize_pending_user_turn_before_error,
|
||||
)
|
||||
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
|
||||
from api.run_journal import (
|
||||
find_run_summary,
|
||||
read_run_events,
|
||||
@@ -8987,10 +8988,15 @@ def _start_chat_stream_for_session(
|
||||
if goal_related:
|
||||
STREAM_GOAL_RELATED[stream_id] = True
|
||||
diag.stage("worker_thread_start") if diag else None
|
||||
backend_is_gateway = webui_gateway_chat_enabled(get_config())
|
||||
worker_target = _run_gateway_chat_streaming if backend_is_gateway else _run_agent_streaming
|
||||
worker_kwargs = {"model_provider": model_provider}
|
||||
if not backend_is_gateway:
|
||||
worker_kwargs["goal_related"] = goal_related
|
||||
thr = threading.Thread(
|
||||
target=_run_agent_streaming,
|
||||
target=worker_target,
|
||||
args=(s.session_id, msg, model, workspace, stream_id, attachments),
|
||||
kwargs={"model_provider": model_provider, "goal_related": goal_related},
|
||||
kwargs=worker_kwargs,
|
||||
daemon=True,
|
||||
)
|
||||
thr.start()
|
||||
|
||||
@@ -1378,12 +1378,60 @@ def _is_provisional_title(current_title: str, messages) -> bool:
|
||||
return current == candidate
|
||||
|
||||
|
||||
def _detect_title_language(text: str) -> str:
|
||||
"""Best-effort language hint for title generation/validation."""
|
||||
s = re.sub(r'\s+', ' ', str(text or '')).strip().lower()
|
||||
if not s:
|
||||
return ''
|
||||
german_markers = {
|
||||
'warum', 'werden', 'wird', 'wurde', 'hier', 'nicht', 'mehr', 'alte', 'alten',
|
||||
'bilder', 'angezeigt', 'session', 'prüfe', 'ich', 'die', 'der', 'das', 'den',
|
||||
'und', 'oder', 'mit', 'für', 'von', 'zu', 'ist', 'sind', 'bitte', 'kannst',
|
||||
}
|
||||
tokens = re.findall(r'[A-Za-zÀ-ÖØ-öø-ÿ]+', s)
|
||||
german_hits = sum(1 for tok in tokens if tok in german_markers)
|
||||
if re.search(r'[äöüß]', s) or german_hits >= 2:
|
||||
return 'de'
|
||||
return ''
|
||||
|
||||
|
||||
def _title_prompt_language_rule(user_text: str) -> str:
|
||||
lang = _detect_title_language(user_text)
|
||||
if lang == 'de':
|
||||
return (
|
||||
"Match the language of the user question.\n"
|
||||
"If the user writes German, output a German title.\n"
|
||||
"German good: Alte Session Bilder, WebUI Attachment-Pfade, Kontextkompression Status.\n"
|
||||
)
|
||||
return "Match the language of the user question.\n"
|
||||
|
||||
|
||||
def _title_language_mismatch(user_text: str, title: str) -> bool:
|
||||
"""Reject obvious English titles for German conversation starts."""
|
||||
if _detect_title_language(user_text) != 'de':
|
||||
return False
|
||||
candidate = str(title or '').strip().lower()
|
||||
if not candidate:
|
||||
return False
|
||||
if _detect_title_language(candidate) == 'de':
|
||||
return False
|
||||
english_markers = {
|
||||
'old', 'image', 'display', 'issue', 'problem', 'discussion', 'conversation',
|
||||
'session', 'title', 'fix', 'bug', 'attachment', 'attachments', 'context',
|
||||
}
|
||||
tokens = re.findall(r'[a-z]+', candidate)
|
||||
english_hits = sum(1 for tok in tokens if tok in english_markers)
|
||||
return english_hits >= 2
|
||||
|
||||
|
||||
def _title_prompts(user_text: str, assistant_text: str) -> tuple[str, list[str]]:
|
||||
qa = f"User question:\n{user_text[:500]}\n\nAssistant answer:\n{assistant_text[:500]}"
|
||||
language_rule = _title_prompt_language_rule(user_text)
|
||||
prompts = [
|
||||
(
|
||||
"Generate a short session title from this conversation start.\n"
|
||||
"Use BOTH the user's question and the assistant's visible answer.\n"
|
||||
f"{language_rule}"
|
||||
"Return only the title text, 3-8 words, as a topic label.\n"
|
||||
"Do not use markdown, bullets, labels, or prefixes like Session Title:.\n"
|
||||
"Do not output a full sentence.\n"
|
||||
@@ -1395,6 +1443,7 @@ def _title_prompts(user_text: str, assistant_text: str) -> tuple[str, list[str]]
|
||||
(
|
||||
"Rewrite this conversation start as a concise noun-phrase title.\n"
|
||||
"Use the actual topic, not the task outcome.\n"
|
||||
f"{language_rule}"
|
||||
"Return title text only.\n"
|
||||
"Do not use markdown, bullets, labels, or prefixes like Session Title:.\n"
|
||||
"Never output acknowledgements, completion status, or meta commentary."
|
||||
@@ -1750,6 +1799,8 @@ def _generate_llm_session_title_for_agent(agent, user_text: str, assistant_text:
|
||||
return None, status, ''
|
||||
title = _sanitize_generated_title(raw)
|
||||
if title:
|
||||
if _title_language_mismatch(user_text, title):
|
||||
return None, 'llm_language_mismatch', str(raw)[:120]
|
||||
return title, status, ''
|
||||
return None, 'llm_invalid', str(raw)[:120]
|
||||
|
||||
@@ -1782,6 +1833,8 @@ def _generate_llm_session_title_via_aux(user_text: str, assistant_text: str, age
|
||||
return None, status, ''
|
||||
title = _sanitize_generated_title(raw)
|
||||
if title:
|
||||
if _title_language_mismatch(user_text, title):
|
||||
return None, 'llm_language_mismatch_aux', str(raw)[:120]
|
||||
return title, status, ''
|
||||
return None, 'llm_invalid_aux', str(raw)[:120]
|
||||
|
||||
@@ -1816,6 +1869,12 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option
|
||||
assistant_text = re.sub(r'\s+', ' ', assistant_text).strip()
|
||||
combined = f"{user_text} {assistant_text}".strip().lower()
|
||||
combined_raw = f"{user_text} {assistant_text}".strip()
|
||||
source_lang = _detect_title_language(user_text)
|
||||
|
||||
if source_lang == 'de' and 'bilder' in combined and 'session' in combined:
|
||||
if 'alt' in combined or 'alte' in combined or 'alten' in combined:
|
||||
return 'Alte Session Bilder'
|
||||
return 'Session Bilder'
|
||||
|
||||
def _contains_latin(text: str) -> bool:
|
||||
return bool(re.search(r'[A-Za-z]', text or ''))
|
||||
|
||||
+11
-3
@@ -116,9 +116,17 @@ def _dirty_suffix(path: Path, timeout=1) -> str:
|
||||
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
|
||||
if ok:
|
||||
return ""
|
||||
# diff-index exits 1 with no output for a dirty tree. Timeouts and real git
|
||||
# failures include a diagnostic; skip the suffix so the base version remains.
|
||||
return "-dirty" if not out else ""
|
||||
# diff-index --quiet exits 1 with no stdout/stderr to *signal* a dirty tree
|
||||
# (not an error). _run_git() substitutes a synthetic "git exited with
|
||||
# status N" diagnostic when both streams are empty, which makes the naive
|
||||
# `if not out` guard always false on dirty trees — silently dropping the
|
||||
# suffix and defeating dev-build cache busting (static/foo.js?v=… stays
|
||||
# identical to the last-committed version). Treat the synthetic shape as
|
||||
# the dirty signal; real errors (timeouts, missing git) carry a different
|
||||
# diagnostic and correctly suppress the suffix.
|
||||
if not out or out.startswith('git exited with status '):
|
||||
return "-dirty"
|
||||
return ""
|
||||
|
||||
|
||||
def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None:
|
||||
|
||||
+27
-3
@@ -616,6 +616,16 @@ function closeLiveStream(sessionId, streamId, source){
|
||||
if(source&&live.source!==source) return;
|
||||
try{live.source.close();}catch(_){ }
|
||||
delete LIVE_STREAMS[sessionId];
|
||||
// closeLiveStream() is called during session-switch teardown for any session
|
||||
// the user is no longer viewing. The stream is still active on the server,
|
||||
// so mark the in-memory INFLIGHT entry for reattach — otherwise
|
||||
// loadSession() returning to this session skips the reattach branch
|
||||
// (`INFLIGHT.reattach` was only set by the storage-load path) and the SSE
|
||||
// is never reopened. The user then sees no streamed tokens until the LLM
|
||||
// finishes and a metadata refresh swaps in the final reply.
|
||||
// If the stream is terminating cleanly, _clearOwnerInflightState() has
|
||||
// already deleted INFLIGHT[sessionId], so this is a safe no-op.
|
||||
if(INFLIGHT[sessionId]) INFLIGHT[sessionId].reattach=true;
|
||||
}
|
||||
|
||||
function closeOtherLiveStreams(activeSid){
|
||||
@@ -648,9 +658,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
closeOtherLiveStreams(activeSid);
|
||||
closeLiveStream(activeSid);
|
||||
|
||||
let assistantText='';
|
||||
let reasoningText='';
|
||||
let liveReasoningText='';
|
||||
// On reconnect, restore accumulated text from INFLIGHT so we don't lose
|
||||
// progress made before the session switch. Without this the closure starts
|
||||
// empty and tokens arriving on the new SSE connection append to nothing —
|
||||
// the already-rendered content vanishes.
|
||||
const _lastLiveAssistant = reconnecting
|
||||
? INFLIGHT[activeSid]?.messages?.findLast?.(m => m.role === 'assistant' && m._live)
|
||||
: null;
|
||||
let assistantText = _lastLiveAssistant ? (_lastLiveAssistant.content || '') : '';
|
||||
let reasoningText = _lastLiveAssistant ? (_lastLiveAssistant.reasoning || '') : '';
|
||||
let liveReasoningText = reasoningText;
|
||||
let visibleInterimSnippets=[];
|
||||
let _latestGoalStatus=null;
|
||||
let _pendingGoalContinuation=null;
|
||||
@@ -2135,6 +2152,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
if(_deferStreamErrorIfPageHidden(source)) return;
|
||||
_closeSource(source);
|
||||
// If the user has switched to a different session, don't attempt to
|
||||
// reconnect — the old stream's EventSource was closed intentionally
|
||||
// during session switch and reconnecting would leak a background stream.
|
||||
if(!_isSessionActivelyViewed(activeSid)) return;
|
||||
if(_terminalStateReached || _streamFinalized){
|
||||
return;
|
||||
}
|
||||
// Attempt one reconnect if the stream is still active server-side
|
||||
if(!_reconnectAttempted && streamId){
|
||||
_reconnectAttempted=true;
|
||||
|
||||
@@ -589,6 +589,13 @@ async function loadSession(sid){
|
||||
S.toolCalls = [];
|
||||
_messagesTruncated = false;
|
||||
_oldestIdx = 0;
|
||||
// Close live SSE streams from the session we're leaving. The error
|
||||
// handler checks _isSessionActivelyViewed() and won't auto-reconnect
|
||||
// for a backgrounded session, preventing leaked connections that would
|
||||
// pump token events into an orphaned closure, freezing the main thread.
|
||||
if (currentSid && currentSid !== sid && typeof closeOtherLiveStreams === 'function') {
|
||||
closeOtherLiveStreams(sid);
|
||||
}
|
||||
_loadingOlder = false;
|
||||
const _msgInner = $('msgInner');
|
||||
if (_msgInner && currentSid !== sid) _msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
|
||||
|
||||
@@ -4535,3 +4535,8 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
text-align:left;
|
||||
unicode-bidi:isolate;
|
||||
}
|
||||
|
||||
/* Cap composer width on very wide displays so the chip-cluster gap stays bounded */
|
||||
@media (min-width:1600px) {
|
||||
.composer-box{max-width:1600px;margin:0 auto;}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Regression tests for preserving live streams across session switches."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
@@ -99,3 +100,74 @@ def test_load_session_reattach_path_uses_attach_live_stream_for_running_sessions
|
||||
assert reattach_pos != -1
|
||||
assert active_pos < reattach_pos
|
||||
assert "{reconnecting:true}" in body[reattach_pos : reattach_pos + 200]
|
||||
|
||||
|
||||
def test_close_live_stream_marks_inflight_for_reattach_on_return():
|
||||
"""When closeLiveStream() tears down a still-active SSE transport (e.g. the
|
||||
user switched to another session), the corresponding INFLIGHT entry must be
|
||||
flagged so loadSession() reopens the SSE on return.
|
||||
|
||||
Without this flag the in-memory INFLIGHT entry stays as it was (no
|
||||
`reattach:true`, which is only set on the storage-load path), so
|
||||
loadSession()'s reattach branch is skipped — the SSE is never reopened and
|
||||
the user sees no streamed tokens until the LLM finishes and a metadata
|
||||
refresh swaps in the final reply.
|
||||
"""
|
||||
body = _function_body(MESSAGES_JS, "closeLiveStream")
|
||||
assert "INFLIGHT" in body, (
|
||||
"closeLiveStream() must touch INFLIGHT so loadSession() reattaches the "
|
||||
"SSE when the user switches back to a still-streaming session"
|
||||
)
|
||||
assert re.search(r"INFLIGHT\[\w+\]\s*&&\s*\(?INFLIGHT\[\w+\]\.reattach\s*=\s*true", body) \
|
||||
or re.search(r"if\s*\(\s*INFLIGHT\[\w+\]\s*\)\s*INFLIGHT\[\w+\]\.reattach\s*=\s*true", body), (
|
||||
"closeLiveStream() must set INFLIGHT[sessionId].reattach = true "
|
||||
"(guarded by an existence check) so loadSession()'s reattach branch fires"
|
||||
)
|
||||
|
||||
|
||||
def test_close_other_live_streams_triggers_reattach_for_backgrounded_sessions():
|
||||
"""closeOtherLiveStreams() during session switch must mark every closed
|
||||
background session for reattach. Otherwise switching back to a session whose
|
||||
stream was closed during the switch leaves the SSE permanently disconnected.
|
||||
"""
|
||||
helper_body = _function_body(MESSAGES_JS, "closeOtherLiveStreams")
|
||||
close_body = _function_body(MESSAGES_JS, "closeLiveStream")
|
||||
# closeOtherLiveStreams delegates per-session teardown to closeLiveStream,
|
||||
# so the reattach flag must be set inside closeLiveStream itself for the
|
||||
# chain to work — this guards the indirection.
|
||||
assert "closeLiveStream(sid)" in helper_body.replace(" ", ""), (
|
||||
"closeOtherLiveStreams() must delegate teardown to closeLiveStream()"
|
||||
)
|
||||
assert "reattach" in close_body, (
|
||||
"closeLiveStream() must set the reattach flag so closeOtherLiveStreams() "
|
||||
"propagates the reattach intent to every backgrounded session"
|
||||
)
|
||||
|
||||
|
||||
def test_load_session_reattaches_when_inflight_is_in_memory_and_marked_for_reattach():
|
||||
"""The session-switch return path must hit attachLiveStream() even when
|
||||
INFLIGHT[sid] is already in memory (i.e. wasn't loaded from storage).
|
||||
|
||||
Before the fix, only the storage-load path set `reattach:true` on INFLIGHT,
|
||||
so a switch-back through an in-memory INFLIGHT entry skipped the reattach
|
||||
branch. Once closeLiveStream() also sets reattach=true, the existing
|
||||
`INFLIGHT[sid].reattach && activeStreamId` gate is enough — this test
|
||||
pins the gate's shape so future refactors don't drop the flag check.
|
||||
"""
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
inflight_idx = body.find("if(INFLIGHT[sid]){")
|
||||
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
|
||||
inflight_block = body[inflight_idx : inflight_idx + 2400]
|
||||
assert "INFLIGHT[sid].reattach" in inflight_block, (
|
||||
"loadSession()'s INFLIGHT branch must gate the SSE reattach on the "
|
||||
"reattach flag so closeLiveStream()'s marking flows through"
|
||||
)
|
||||
reattach_gate = re.search(
|
||||
r"if\(INFLIGHT\[sid\]\.reattach\s*&&\s*activeStreamId.*?attachLiveStream\(sid, activeStreamId",
|
||||
inflight_block,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert reattach_gate, (
|
||||
"loadSession() must reattach via attachLiveStream() when "
|
||||
"INFLIGHT[sid].reattach && activeStreamId"
|
||||
)
|
||||
@@ -116,7 +116,14 @@ def test_named_custom_provider_models_endpoint_network_error_uses_short_timeout(
|
||||
observed_timeouts = []
|
||||
|
||||
def fake_urlopen(req, timeout=10):
|
||||
observed_timeouts.append(timeout)
|
||||
# Only record timeouts for the broken-proxy custom endpoint — unrelated
|
||||
# background probes (Copilot token fetch, OpenRouter free-tier discovery, etc.)
|
||||
# also call urlopen during get_available_models() and would otherwise pollute
|
||||
# the assertion. The contract we're pinning: the broken-proxy /v1/models call
|
||||
# uses CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, not the urllib default 10.
|
||||
full_url = getattr(req, "full_url", "")
|
||||
if "broken.example" in str(full_url):
|
||||
observed_timeouts.append(timeout)
|
||||
raise urllib.error.URLError("timed out")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
@@ -9,6 +9,7 @@ Four optimizations to reduce session-switch latency:
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -487,10 +488,20 @@ class TestSessionSwitchCancellation:
|
||||
|
||||
def test_loading_older_reset_on_session_switch(self):
|
||||
"""loadSession must reset _loadingOlder when switching sessions."""
|
||||
# Find the reset block in loadSession
|
||||
marker = "_messagesTruncated = false;\n _oldestIdx = 0;\n _loadingOlder = false;"
|
||||
idx = SESSIONS_JS.find(marker)
|
||||
assert idx >= 0, (
|
||||
# Locate the on-switch reset block — it lives in the `if (currentSid !== sid || forceReload)`
|
||||
# arm of loadSession. Match by the surrounding state-resets rather than by a fragile
|
||||
# multi-line substring, so unrelated code (like the closeOtherLiveStreams teardown
|
||||
# that was inserted between _oldestIdx and _loadingOlder) doesn't break the test.
|
||||
switch_arm = re.search(
|
||||
r"if \(currentSid !== sid \|\| forceReload\) \{(.*?)\n \}",
|
||||
SESSIONS_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert switch_arm, "loadSession's session-switch reset arm not found"
|
||||
block = switch_arm.group(1)
|
||||
assert "_messagesTruncated = false;" in block
|
||||
assert "_oldestIdx = 0;" in block
|
||||
assert "_loadingOlder = false;" in block, (
|
||||
"loadSession must reset _loadingOlder=false on session switch "
|
||||
"to prevent a stale _loadOlderMessages lock from blocking the "
|
||||
"new session's scroll-to-top loading."
|
||||
@@ -517,13 +528,20 @@ class TestSessionSwitchCancellation:
|
||||
|
||||
def test_messages_truncated_reset_on_switch(self):
|
||||
"""loadSession must reset _messagesTruncated on session switch."""
|
||||
marker = "_messagesTruncated = false;\n _oldestIdx = 0;\n _loadingOlder = false;"
|
||||
idx = SESSIONS_JS.find(marker)
|
||||
assert idx >= 0, (
|
||||
switch_arm = re.search(
|
||||
r"if \(currentSid !== sid \|\| forceReload\) \{(.*?)\n \}",
|
||||
SESSIONS_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert switch_arm, "loadSession's session-switch reset arm not found"
|
||||
block = switch_arm.group(1)
|
||||
assert "_messagesTruncated = false;" in block, (
|
||||
"_messagesTruncated must be reset to false on session switch "
|
||||
"to prevent the scroll-to-top handler from trying to load "
|
||||
"older messages from the previous session."
|
||||
)
|
||||
assert "_oldestIdx = 0;" in block
|
||||
assert "_loadingOlder = false;" in block
|
||||
|
||||
def test_oldest_idx_reset_prevents_wrong_cursor(self):
|
||||
"""_oldestIdx=0 after switch prevents passing stale cursor to API."""
|
||||
|
||||
@@ -761,9 +761,13 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se
|
||||
until the final done snapshot redraws the whole turn.
|
||||
"""
|
||||
src = (REPO_ROOT / "static/messages.js").read_text()
|
||||
assert "let reasoningText=''" in src, \
|
||||
# reasoningText is initialised at closure scope in attachLiveStream.
|
||||
# On initial connect it defaults to ''; on reconnect it restores from
|
||||
# INFLIGHT so the already-rendered content survives the session switch.
|
||||
assert ("let reasoningText=''" in src
|
||||
or "let reasoningText = _lastLiveAssistant" in src), \
|
||||
"messages.js must track streamed reasoning text separately from assistant text"
|
||||
assert "let liveReasoningText=''" in src or 'let liveReasoningText = ""' in src, \
|
||||
assert ("let liveReasoningText=''" in src or "let liveReasoningText = reasoningText" in src), \
|
||||
"messages.js must track the currently active reasoning segment separately from cumulative reasoning"
|
||||
assert "source.addEventListener('reasoning'" in src or 'source.addEventListener("reasoning"' in src, \
|
||||
"messages.js must listen for live reasoning SSE events"
|
||||
|
||||
@@ -154,8 +154,14 @@ class TestReconnectAccumulatorPreservation:
|
||||
)
|
||||
assert m, "attachLiveStream prelude not found"
|
||||
prelude = m.group(0)
|
||||
assert "let assistantText=''" in prelude or 'let assistantText = ""' in prelude, (
|
||||
"assistantText must be initialised to '' at closure scope — "
|
||||
# On initial connect, assistantText and reasoningText are initialised to ''
|
||||
# at closure scope (the ternary defaults to '' when reconnecting is false
|
||||
# or INFLIGHT has no _live assistant message). On reconnect, they restore
|
||||
# from INFLIGHT so the already-rendered content survives the session switch.
|
||||
assert ("let assistantText=''" in prelude
|
||||
or 'let assistantText = _lastLiveAssistant' in prelude
|
||||
or 'let assistantText = ""' in prelude), (
|
||||
"assistantText must be initialised at closure scope — "
|
||||
"this is the only legitimate reset; _wireSSE must not re-reset"
|
||||
)
|
||||
|
||||
|
||||
@@ -198,6 +198,71 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
|
||||
self.assertEqual(captured.get('base_url'), 'http://openrouter:4000/v1')
|
||||
self.assertEqual(captured.get('api_key'), 'test-title-api-key')
|
||||
|
||||
def test_title_prompt_requires_matching_user_language(self):
|
||||
"""German conversation starts should not invite English title output."""
|
||||
from api.streaming import generate_title_raw_via_aux
|
||||
|
||||
mock_resp = types.SimpleNamespace(
|
||||
choices=[
|
||||
types.SimpleNamespace(
|
||||
message=types.SimpleNamespace(content='Alte Session Bilder'),
|
||||
finish_reason='stop',
|
||||
)
|
||||
]
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_call_llm(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return mock_resp
|
||||
|
||||
with _patch_tg_config({'provider': '', 'model': 'title-model', 'base_url': ''}):
|
||||
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
|
||||
result, status = generate_title_raw_via_aux(
|
||||
user_text='Warum werden hier die Bilder der alten Session nicht mehr angezeigt?',
|
||||
assistant_text='Ich prüfe die Attachment-Pfade im WebUI.',
|
||||
)
|
||||
|
||||
self.assertEqual(result, 'Alte Session Bilder')
|
||||
self.assertEqual(status, 'llm_aux')
|
||||
messages = captured.get('messages') or []
|
||||
self.assertIn('Match the language of the user question', messages[0]['content'])
|
||||
self.assertIn('If the user writes German, output a German title', messages[0]['content'])
|
||||
|
||||
def test_german_source_rejects_english_aux_title(self):
|
||||
"""Regression: an English aux title must not overwrite a German conversation."""
|
||||
from api.streaming import _generate_llm_session_title_via_aux
|
||||
|
||||
mock_resp = types.SimpleNamespace(
|
||||
choices=[
|
||||
types.SimpleNamespace(
|
||||
message=types.SimpleNamespace(content='Old Session Image Display Issue'),
|
||||
finish_reason='stop',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with _patch_tg_config({'provider': '', 'model': 'title-model', 'base_url': ''}):
|
||||
with patch('agent.auxiliary_client.call_llm', return_value=mock_resp, create=True):
|
||||
title, status, raw_preview = _generate_llm_session_title_via_aux(
|
||||
'Warum werden hier die Bilder der alten Session nicht mehr angezeigt?',
|
||||
'Ich prüfe die Attachment-Pfade im WebUI.',
|
||||
)
|
||||
|
||||
self.assertIsNone(title)
|
||||
self.assertEqual(status, 'llm_language_mismatch_aux')
|
||||
self.assertEqual(raw_preview, 'Old Session Image Display Issue')
|
||||
|
||||
def test_german_fallback_keeps_german_topic_words(self):
|
||||
from api.streaming import _fallback_title_from_exchange
|
||||
|
||||
title = _fallback_title_from_exchange(
|
||||
'Warum werden hier die Bilder der alten Session nicht mehr angezeigt?',
|
||||
'Ich prüfe die Rendering- und Attachment-Pfade im WebUI.',
|
||||
)
|
||||
|
||||
self.assertEqual(title, 'Alte Session Bilder')
|
||||
|
||||
def test_configured_api_key_is_not_sent_to_caller_supplied_route(self):
|
||||
"""Regression: title task keys must not leak to explicit fallback routes.
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
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_sse_delta,
|
||||
_gateway_stream_usage,
|
||||
webui_chat_backend_mode,
|
||||
webui_gateway_chat_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_chat_backend_is_default_off_for_truthy_values():
|
||||
for value in (None, "", "1", "true", "yes", "on", "enabled", "runner-local"):
|
||||
env = {}
|
||||
if value is not None:
|
||||
env["HERMES_WEBUI_CHAT_BACKEND"] = value
|
||||
assert webui_chat_backend_mode({}, env) == "legacy"
|
||||
assert webui_gateway_chat_enabled({}, env) is False
|
||||
|
||||
|
||||
def test_gateway_chat_backend_only_accepts_explicit_gateway_aliases():
|
||||
for value in ("gateway", "api_server", "api-server", " Gateway "):
|
||||
assert webui_chat_backend_mode({}, {"HERMES_WEBUI_CHAT_BACKEND": value}) == "gateway"
|
||||
assert webui_gateway_chat_enabled({}, {"HERMES_WEBUI_CHAT_BACKEND": value}) is True
|
||||
|
||||
|
||||
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_backend_env_wins_over_config_and_stays_safe():
|
||||
assert webui_chat_backend_mode(
|
||||
{"webui_chat_backend": "gateway"},
|
||||
{"HERMES_WEBUI_CHAT_BACKEND": "legacy-direct"},
|
||||
) == "legacy"
|
||||
|
||||
|
||||
def test_gateway_sse_delta_extracts_openai_chat_chunks():
|
||||
assert _gateway_sse_delta({"choices": [{"delta": {"content": "hel"}}]}) == "hel"
|
||||
assert _gateway_sse_delta({"choices": [{"message": {"content": "done"}}]}) == "done"
|
||||
assert _gateway_sse_delta({"choices": [{"delta": {}}]}) == ""
|
||||
|
||||
|
||||
def test_gateway_stream_usage_normalizes_token_names():
|
||||
assert _gateway_stream_usage({"usage": {"prompt_tokens": 7, "completion_tokens": 3}}) == {
|
||||
"input_tokens": 7,
|
||||
"output_tokens": 3,
|
||||
"estimated_cost": 0,
|
||||
}
|
||||
assert _gateway_stream_usage({"usage": {"input_tokens": 5, "output_tokens": 2, "estimated_cost_usd": 0.01}}) == {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 2,
|
||||
"estimated_cost": 0.01,
|
||||
}
|
||||
assert _gateway_stream_usage({}) == {}
|
||||
|
||||
|
||||
def test_gateway_chat_worker_translates_sse_and_persists_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")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
yield b'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n'
|
||||
yield b'data: {"choices":[{"delta":{"content":"lo"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}\n\n'
|
||||
yield b'data: [DONE]\n\n'
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
captured["url"] = req.full_url
|
||||
captured["headers"] = dict(req.header_items())
|
||||
captured["body"] = req.data.decode("utf-8")
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
|
||||
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
|
||||
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
s = new_session()
|
||||
stream_id = "stream-gateway-test"
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = "Say hello"
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = 123
|
||||
s.save()
|
||||
STREAMS[stream_id] = create_stream_channel()
|
||||
|
||||
gateway_chat._run_gateway_chat_streaming(
|
||||
s.session_id,
|
||||
"Say hello",
|
||||
"test-model",
|
||||
str(tmp_path),
|
||||
stream_id,
|
||||
[],
|
||||
)
|
||||
|
||||
saved = models.get_session(s.session_id)
|
||||
assert [m["role"] for m in saved.messages] == ["user", "assistant"]
|
||||
assert saved.messages[-1]["content"] == "hello"
|
||||
assert saved.active_stream_id is None
|
||||
assert stream_id not in STREAMS
|
||||
assert captured["url"] == "http://gateway.local/v1/chat/completions"
|
||||
assert captured["headers"]["Authorization"] == "Bearer secret-token"
|
||||
assert captured["headers"]["X-hermes-session-id"] == s.session_id
|
||||
assert captured["headers"]["X-hermes-session-key"] == f"webui:{s.session_id}"
|
||||
assert '"stream": true' in captured["body"]
|
||||
Reference in new issue
Block a user