Merge pull request #1652 from nesquena/stage-296

Release v0.50.296 — 3-PR batch (TPS in headers + session save mode + Codex OAuth onboarding)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-04 14:40:34 -07:00
commit e6cf801ef4
29 files changed
+1308 -338

No files matched your search

+36
View File
@@ -1,5 +1,41 @@
# Hermes Web UI -- Changelog
## [v0.50.296] — 2026-05-04
### Fixed (3 PRs — closes #1406, #1617; refs #1362)
- **Per-turn TPS now visible in assistant message headers (default-off, opt-in via Preferences)** (#1640 by @Michaelyklam, closes #1617) — UX gate **APPROVED by @aronprins** with default-off + opt-in setting addition. Previously `_turnTps` calculation existed in `api/streaming.py` but was rendered into a global titlebar `tpsStat` element that's been hidden by default since v0.50.x. New `show_tps` boolean setting in Preferences (default `false`) renders an inline `.msg-tps-inline` chip in each assistant message header when enabled. Useful for power users tuning local-model setups (LM Studio, Ollama, llama.cpp, vLLM) where TPS varies turn-to-turn based on context length, parallel slots, and prompt complexity. **Backend changes:** `api/metering.py` adds explicit `tps_available` field (boolean — strict, requires both real exact token count AND backend-measured turn duration), drops placeholder `0.0` TPS when no real reading exists, switches live counting from character-count-derived text length to streaming-callback deltas. Final `_turnTps` computed from exact final output token usage divided by backend-measured turn duration when both available, persisted on assistant message and sent in `done` payload only when both signals available. **Hot-apply:** Preferences autosave updates `window._showTps` global, clears the message render cache, and re-renders messages — toggling the setting reflects in open tabs without refresh. UI evidence under `docs/pr-media/1640/` showing default-off transcript, hot-apply with TPS visible, and the Settings → Preferences toggle.
- **Operator-level config knob for first-turn session save timing** (#1648 by @Michaelyklam, closes #1406) — operators wanting crash-resilience for the user's first prompt (vs accepting the first prompt being in-memory-only until streaming begins) now have a `webui.session_save_mode` config.yaml knob with values `deferred` (default — preserves the v0.50.230 fix for #1171 orphan-Untitled files) and `eager`. **Eager mode** materializes the user message into `s.messages` before launching the agent thread, plus updates `_apply_core_sync_or_error_marker` (WAL/repair path) and the streaming-thread context-build path (`_drop_checkpointed_current_user_from_context`) to avoid double-counting the user turn. Implementation matches @nesquena-hermes's prescribed shape from #1406's maintainer comment 1:1 — no Settings UI toggle (operator-level only), default stays deferred (orphan-Untitled hygiene preserved), threshold is "≥1 user message" not "did `new_session()` get called" (so empty-new-chat-then-switch-away doesn't recreate the orphan-file class). Validated `_WEBUI_SESSION_SAVE_MODES = {"deferred", "eager"}`; unknown values fail closed to `deferred`. 132-LOC test file covering both modes + WAL/repair interaction + duplicate-context filtering.
- **In-app OAuth onboarding flow for OpenAI Codex** (#1650 by @Michaelyklam, refs #1362) — three new endpoints: `POST /api/onboarding/oauth/start` (initiates the device-code flow), `GET /api/onboarding/oauth/poll?flow_id=...` (returns high-level status: `pending|success|expired|cancelled|error`), `POST /api/onboarding/oauth/cancel` (aborts an in-flight flow). **Server-owned lifecycle:** all sensitive provider state (device_auth_id, code_verifier, authorization_code, access_token, refresh_token, token_data) lives in a process-local `_OAUTH_FLOWS` dict keyed by an opaque WebUI-local `flow_id` (UUID4). Browser only sees `flow_id`, `user_code`, `verification_uri`, status — never raw OAuth lifecycle secrets. 15-minute flow timeout. **Token persistence:** successful Codex credentials write to the **active profile's** `auth.json` `credential_pool.openai-codex` (atomic tmp+rename, chmod 0o600 on tmp BEFORE rename so final file never has world-readable window, defense-in-depth post-rename chmod). Allowlist `_ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex"}`; explicit blocklist for anthropic/claude/nous/qwen/gemini/minimax/copilot (rejected with generic "Only OpenAI Codex OAuth is supported in WebUI onboarding right now" — no internal triage state leaked). Implementation matches @nesquena-hermes's prescribed shape from #1362's maintainer comment 1:1 (server-owned state machine, no client-side device codes, abort endpoint, profile-scoped storage, opt-in). Updated `static/onboarding.js` for the `openai-codex` OAuth-pending path with clickable verification URL, prominent user code with copy-to-clipboard, abort button. Updated Codex auth endpoints to current Hermes Agent Codex protocol: `https://auth.openai.com/api/accounts/deviceauth/usercode`, `.../api/accounts/deviceauth/token`, `.../oauth/token`. 182-LOC test file covering route shape, secret-leak prevention, allowlist, expiration, cancellation, profile-scoped credential write, frontend endpoint usage, and the unsupported-provider note copy update. **First step on the #1362 sprint roadmap** — Anthropic Claude OAuth is the planned v2.
### Tests
4255 → **4284 passing** (+29 regression tests across `tests/test_issue1617_tps_message_header.py` (31), `tests/test_session_save_mode.py` (~13 new + edits), `tests/test_issue1362_codex_oauth_onboarding.py` (9), plus existing test updates for context-window-persistence, preferences-autosave). 0 regressions. Full suite ~120s.
### Pre-release verification
- **Opus advisor on stage-296 combined diff: SHIP verdict.** All 14 verification questions cleared, with focused OAuth security audit on #1650 (in-memory flow lifecycle correct, lock not held during network IO, no flow_id leakage path, allowlist fail-closed, chmod-before-rename correctly implemented per the prior security-fix pattern, sensitive fields scrubbed on every terminal status transition, no internal triage state in error messages). Two minor follow-ups absorbed in-release per <20-LOC defensive policy:
- `_get_active_hermes_home()` exception fallback now logs a `logger.warning(...)` so silent profile-corruption fallback is observable in logs.
- Codex credential pool find-loop now accepts both `source == "manual:device_code"` (current code) AND `source == "oauth_device"` (legacy from prior Codex OAuth implementations) so users with prior creds get their entry updated in-place rather than accumulating a stale duplicate pool entry.
- **#1640 has @aronprins UX-gate APPROVED** (May 04 19:24 UTC) after a tighten request landed (default-off setting + Settings → Preferences toggle, hot-applied without refresh).
- **#1648 implements @nesquena-hermes's prescribed shape** from the #1406 maintainer comment 1:1.
- **#1650 implements @nesquena-hermes's prescribed shape** from the #1362 maintainer comment 1:1, with explicit security-audit alignment (server-owned device codes, opaque flow_id, profile-scoped storage, blocklist for known-OAuth providers awaiting v2).
- **JS syntax**: 5 modified `.js` files (`boot.js`, `messages.js`, `onboarding.js`, `panels.js`, `ui.js`) clean.
- **Browser API sanity**: 11/11 endpoints OK on stage server.
- **Conflict resolution**: clean auto-merge across all 3 PRs (rebased #1640 onto current master from 10-commits-behind base; #1648 + #1650 already on current master; no overlapping code regions across the 3 PRs in `api/streaming.py`, `api/routes.py`, or `static/`).
### Authors
- @Michaelyklam — 3 PRs (#1640, #1648, #1650)
@Michaelyklam continues the strong contribution pattern from #1597, #1598, #1600, #1601, #1621, #1637 — this is now 9 merged PRs across the v0.50.292-296 release window.
### Trust boundary note
This release ships the first user-facing OAuth flow in the WebUI. Token storage path, atomic write semantics, chmod timing, server-side flow state, and the allowlist/blocklist pattern are all in scope for security reviewers reviewing v0.50.296. The Hermes Agent CLI's `auth.json` format is the source-of-truth contract — both the WebUI and CLI write the same `credential_pool.openai-codex` shape, so credentials added via either surface are usable by either surface.
## [v0.50.295] — 2026-05-04
### Fixed (3 PRs — closes #1360, #1451, #1463, #1618, #1619)
+1 -1
View File
@@ -2,7 +2,7 @@
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.50.295 (May 04, 2026) — 4255 tests collected
> Last updated: v0.50.296 (May 04, 2026) — 4284 tests collected
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
+2 -2
View File
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
*Last updated: v0.50.295, May 04, 2026*
*Total automated tests collected: 4255*
*Last updated: v0.50.296, May 04, 2026*
*Total automated tests collected: 4284*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
+28
View File
@@ -199,6 +199,10 @@ def _get_config_path() -> Path:
return HOME / ".hermes" / "config.yaml"
_WEBUI_SESSION_SAVE_MODES = {"deferred", "eager"}
_DEFAULT_WEBUI_SESSION_SAVE_MODE = "deferred"
def get_config() -> dict:
"""Return the cached config dict, loading from disk if needed."""
if not _cfg_cache:
@@ -206,6 +210,28 @@ def get_config() -> dict:
return _cfg_cache
def get_webui_session_save_mode(config_data: dict | None = None) -> str:
"""Return the validated first-turn session persistence mode.
``deferred`` preserves the current first-turn sidecar behaviour: persist
pending_user_message/runtime fields before streaming, then merge the turn
after the agent finishes. ``eager`` additionally checkpoints the current
user turn into ``messages`` before launching the agent thread. Unknown
values fail closed to ``deferred`` so a typo never reintroduces eager disk
writes unexpectedly.
"""
active_cfg = config_data if isinstance(config_data, dict) else cfg
webui_cfg = active_cfg.get("webui", {}) if isinstance(active_cfg, dict) else {}
if not isinstance(webui_cfg, dict):
return _DEFAULT_WEBUI_SESSION_SAVE_MODE
mode = webui_cfg.get("session_save_mode", _DEFAULT_WEBUI_SESSION_SAVE_MODE)
if isinstance(mode, str):
normalized = mode.strip().lower()
if normalized in _WEBUI_SESSION_SAVE_MODES:
return normalized
return _DEFAULT_WEBUI_SESSION_SAVE_MODE
def reload_config() -> None:
"""Reload config.yaml from the active profile's directory."""
global _cfg_mtime
@@ -3082,6 +3108,7 @@ _SETTINGS_DEFAULTS = {
"onboarding_completed": False,
"send_key": "enter", # 'enter' or 'ctrl+enter'
"show_token_usage": False, # show input/output token badge below assistant messages
"show_tps": False, # show tokens-per-second chip in assistant message headers
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
@@ -3207,6 +3234,7 @@ _SETTINGS_ENUM_VALUES = {
_SETTINGS_BOOL_KEYS = {
"onboarding_completed",
"show_token_usage",
"show_tps",
"show_cli_sessions",
"sync_to_insights",
"check_for_updates",
+31 -24
View File
@@ -1,17 +1,17 @@
"""
Hermes Web UI -- Streaming performance metering.
Tracks Tokens Per Second (TPS) across all active WebUI sessions, and the
HIGH/LOW TPS values observed over the past 60 minutes. Metering data is
emitted via SSE events so the header label can update live during a stream.
Tracks Tokens Per Second (TPS) across active WebUI streams. Metering data is
emitted via SSE events so a streaming assistant message can update its own
header while the turn is running.
Architecture
────────────
Each streaming session is tracked independently. TPS per session is:
Each streaming session is tracked independently. TPS per stream is:
session_tps = total_tokens / (last_token_ts - first_token_ts)
stream_tps = total_stream_deltas / (last_delta_ts - first_delta_ts)
The global tps is the average of all currently active sessions' TPS values.
The global tps is the average of all currently active streams' TPS values.
This correctly represents the system's real-time capacity regardless of how
many sessions are running or how long each has been streaming.
@@ -19,8 +19,8 @@ For HIGH/LOW tracking, every stats snapshot records the current global tps
(only when > 0 — idle periods are skipped) into a rolling 60-minute history.
The max/min of that history gives the peak throughput observed over the past hour.
The ticker in streaming.py calls get_interval() — it returns 1.0 when sessions
are actively receiving tokens so the header updates at 1 Hz, and 10.0 when idle
The ticker in streaming.py calls get_interval() — it returns 1.0 when streams
are actively receiving output deltas so message headers update at 1 Hz, and 10.0 when idle
so the ticker exits and no idle readings are emitted.
Usage from api/streaming.py
@@ -28,15 +28,17 @@ Usage from api/streaming.py
from api.metering import meter
meter().begin_session(stream_id) # stream starts
meter().record_token(stream_id, running_output) # per output token
meter().record_reasoning(stream_id, running_reasoning_len) # per reasoning token
meter().record_token(stream_id, running_output_deltas)
meter().record_reasoning(stream_id, running_reasoning_deltas)
The SSE `metering` event payload:
{
"tps": 47.3, # average TPS across active sessions (real-time)
"high": 52.1, # highest average TPS observed in the past 60 minutes
"low": 31.4, # lowest average TPS (excl. readings < 1 tps, to ignore idle)
"active": 1, # sessions currently streaming
"tps": 47.3, # omitted/null until a real reading exists
"tps_available": true, # frontend must hide TPS when false
"estimated": false, # never show byte/character-size estimates
"high": 52.1,
"low": 31.4,
"active": 1,
}
"""
@@ -60,9 +62,9 @@ class _SessionMeter:
def total_tokens(self) -> int:
return self.output_tokens + self.reasoning_tokens
def tps(self) -> float:
def tps(self) -> float | None:
if self.first_token_ts == 0.0 or self.last_token_ts <= self.first_token_ts:
return 0.0
return None
return self.total_tokens() / (self.last_token_ts - self.first_token_ts)
@@ -148,12 +150,15 @@ class GlobalMeter:
if not self._sessions:
self._window_start = now
# Compute global tps: average of per-session TPS values
# Compute global tps: average only streams with a real reading. The
# UI hides TPS entirely when this is unavailable instead of showing
# placeholder/estimated values.
active = [s for s in self._sessions.values() if s.first_token_ts > 0]
if active:
global_tps = sum(s.tps() for s in active) / len(active)
active_tps = [v for s in active for v in [s.tps()] if v is not None and v > 0]
if active_tps:
global_tps = sum(active_tps) / len(active_tps)
else:
global_tps = 0.0
global_tps = None
# Prune readings older than 1 hour
cutoff = now - _HOUR_SECS
@@ -162,7 +167,7 @@ class GlobalMeter:
# Only record this snapshot for HIGH/LOW if there is active work.
# This prevents idle periods from flooding the history and keeps
# HIGH/LOW meaningful for the past hour of actual throughput.
if global_tps > 0:
if global_tps is not None and global_tps > 0:
self._readings.append((now, global_tps))
# HIGH/LOW from the past hour (skip near-zero idle readings)
@@ -171,9 +176,11 @@ class GlobalMeter:
low = min(active_readings) if active_readings else 0.0
return {
'tps': round(global_tps, 1),
'high': round(high, 1),
'low': round(low, 1),
'tps': round(global_tps, 1) if global_tps is not None else None,
'tps_available': global_tps is not None,
'estimated': False,
'high': round(high, 1) if high else None,
'low': round(low, 1) if low else None,
'active': len(self._sessions),
}
+17 -9
View File
@@ -629,18 +629,26 @@ def _apply_core_sync_or_error_marker(
# prompt submitted just before a server restart, so materialize it before
# clearing runtime stream state.
if len(session.messages) != 0:
_pending_text = " ".join(str(session.pending_user_message or "").split())
_already_checkpointed = False
if _pending_text and session.messages:
_last_msg = session.messages[-1]
if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
_last_text = " ".join(str(_last_msg.get('content') or "").split())
_already_checkpointed = _last_text == _pending_text
_recovered_ts = int(time.time())
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
_recovered_ts = int(session.pending_started_at)
recovered = {
'role': 'user',
'content': session.pending_user_message,
'timestamp': _recovered_ts,
'_recovered': True,
}
if session.pending_attachments:
recovered['attachments'] = list(session.pending_attachments)
session.messages.append(recovered)
if not _already_checkpointed:
recovered = {
'role': 'user',
'content': session.pending_user_message,
'timestamp': _recovered_ts,
'_recovered': True,
}
if session.pending_attachments:
recovered['attachments'] = list(session.pending_attachments)
session.messages.append(recovered)
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
+403 -157
View File
@@ -1,196 +1,442 @@
"""In-app OAuth flow implementations for providers like OpenAI Codex.
"""In-app OAuth flow implementations for onboarding.
Uses only stdlib (urllib.request, json, time) — no external dependencies.
Credentials are stored in ~/.hermes/auth.json under the credential_pool.
The browser receives only WebUI-local flow metadata (flow_id, user_code,
verification_uri, high-level status). Provider device/auth codes and OAuth
tokens stay server-side and are persisted to the active Hermes profile's
``auth.json`` credential_pool.
"""
from __future__ import annotations
import json
import logging
import os
import stat
import threading
import time
import uuid
import urllib.request
import urllib.parse
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Compatibility for older helper tests and self-heal code that import these.
AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
# ── Codex OAuth constants (from hermes_cli/auth.py) ──
CODEX_CLIENT_ID = "pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh"
CODEX_AUTH_URL = "https://auth.openai.com/oauth/device/authorize"
CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_SCOPE = "openid profile email offline_access"
CODEX_GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
CODEX_ISSUER = "https://auth.openai.com"
CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_VERIFICATION_URI = f"{CODEX_ISSUER}/codex/device"
CODEX_USER_CODE_URL = f"{CODEX_ISSUER}/api/accounts/deviceauth/usercode"
CODEX_DEVICE_TOKEN_URL = f"{CODEX_ISSUER}/api/accounts/deviceauth/token"
CODEX_TOKEN_URL = f"{CODEX_ISSUER}/oauth/token"
CODEX_REDIRECT_URI = f"{CODEX_ISSUER}/deviceauth/callback"
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
CODEX_FLOW_MAX_WAIT_SECONDS = 15 * 60
_ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex"}
_REJECTED_ONBOARDING_OAUTH_PROVIDERS = {
"anthropic",
"claude",
"claude-code",
"nous",
"qwen-oauth",
"gemini-cli",
"google-gemini-cli",
"minimax",
"minimax-oauth",
"copilot",
"copilot-acp",
}
_OAUTH_FLOWS: dict[str, dict[str, Any]] = {}
_OAUTH_FLOWS_LOCK = threading.Lock()
# ── auth.json helpers ──
def _get_active_hermes_home() -> Path:
try:
from api.profiles import get_active_hermes_home
def _read_auth_json():
"""Read auth.json and return parsed dict, or empty dict."""
if AUTH_JSON_PATH.exists():
return Path(get_active_hermes_home())
except Exception as exc:
# Per Opus advisor on stage-296: log the silent fallback so a corrupt
# profile state ending up writing tokens to ~/.hermes (instead of the
# active profile) is observable in logs rather than failing silently.
logger.warning(
"Falling back to ~/.hermes for OAuth credential storage: "
"active-profile resolution failed: %s",
exc,
)
return Path.home() / ".hermes"
# ── legacy auth.json helpers ────────────────────────────────────────────────
def _read_auth_json(auth_path: Path | None = None) -> dict[str, Any]:
"""Read auth.json and return parsed dict, or an empty compatible store."""
path = auth_path or AUTH_JSON_PATH
if path.exists():
try:
return json.loads(AUTH_JSON_PATH.read_text())
loaded = json.loads(path.read_text(encoding="utf-8"))
return loaded if isinstance(loaded, dict) else {}
except json.JSONDecodeError as exc:
logger.warning("Failed to parse %s: %s", AUTH_JSON_PATH, exc)
logger.warning("Failed to parse %s: %s", path, exc)
return {}
return {}
def read_auth_json():
"""Public wrapper for _read_auth_json.
Used by the streaming layer's credential self-heal logic (#1401) to
re-read credentials without coupling to the private helper.
"""
"""Public wrapper for streaming credential self-heal code."""
return _read_auth_json()
def _write_auth_json(data):
"""Atomically write auth.json via temp-file rename.
def _write_auth_json(data: dict[str, Any], auth_path: Path | None = None) -> Path:
"""Atomically write auth.json with owner-only permissions.
SECURITY: auth.json contains OAuth access/refresh tokens. ``tmp.replace()``
preserves the temp file's mode (created with the process umask, typically
0644 or 0664), NOT the prior auth.json mode. Without an explicit chmod,
tokens land world-readable on shared systems. Set 0600 BEFORE the rename
so there is no window where the final file is world-readable.
(Opus pre-release advisor finding.)
OAuth access/refresh tokens live in this file. The temp file is chmod 0600
before rename so the final path never inherits a permissive process umask.
"""
import os, stat
AUTH_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = AUTH_JSON_PATH.with_suffix('.tmp')
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
path = auth_path or AUTH_JSON_PATH
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
try:
tmp.chmod(0o600)
except OSError as e:
# Best-effort: if chmod fails (e.g. on a filesystem that doesn't
# support POSIX modes), don't abort. The startup permission fixer
# in api.startup will sweep auth.json on the next process start.
logger.warning("Failed to chmod 0600 on %s: %s", tmp, e)
tmp.replace(AUTH_JSON_PATH)
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
try:
tmp.chmod(0o600)
except OSError as exc:
logger.warning("Failed to chmod 0600 on %s: %s", tmp, exc)
tmp.replace(path)
try:
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
return path
finally:
try:
if tmp.exists():
tmp.unlink()
except OSError:
pass
# ── Codex device-code flow ──
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def start_codex_device_code():
"""Start Codex OAuth device-code flow.
Returns dict: { device_code, user_code, verification_uri, expires_in, interval }
Raises RuntimeError on network error.
"""
params = {
"client_id": CODEX_CLIENT_ID,
"scope": CODEX_SCOPE,
def _persist_codex_credentials(hermes_home: Path, token_data: dict[str, Any]) -> Path:
"""Persist Codex OAuth credentials to active-profile auth.json."""
access_token = str(token_data.get("access_token") or "").strip()
refresh_token = str(token_data.get("refresh_token") or "").strip()
if not access_token:
raise RuntimeError("Codex token exchange did not return an access_token")
auth_path = Path(hermes_home) / "auth.json"
auth = _read_auth_json(auth_path)
auth.setdefault("version", 1)
pool = auth.setdefault("credential_pool", {})
if not isinstance(pool, dict):
pool = {}
auth["credential_pool"] = pool
entries = pool.setdefault("openai-codex", [])
if not isinstance(entries, list):
entries = []
pool["openai-codex"] = entries
now = _now_iso()
entry = None
# Per Opus advisor on stage-296: also accept the legacy `source ==
# "oauth_device"` value so users with prior Codex OAuth credentials
# (written by older WebUI versions before this PR's source-key change)
# get their existing entry updated in-place rather than accumulating a
# stale duplicate pool entry.
_accept_sources = {"manual:device_code", "oauth_device"}
for candidate in entries:
if isinstance(candidate, dict) and candidate.get("source") in _accept_sources:
entry = candidate
break
if entry is None:
entry = {
"id": "codex-oauth-" + uuid.uuid4().hex[:12],
"label": "Codex OAuth",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"base_url": CODEX_BASE_URL,
"created_at": now,
}
entries.insert(0, entry)
entry.update(
{
"label": "Codex OAuth",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": access_token,
"refresh_token": refresh_token,
"base_url": CODEX_BASE_URL,
"last_refresh": now,
"updated_at": now,
}
)
auth["updated_at"] = now
path = _write_auth_json(auth, auth_path)
try:
from api.config import invalidate_credential_pool_cache
invalidate_credential_pool_cache("openai-codex")
except Exception:
logger.debug("Failed to invalidate openai-codex credential cache", exc_info=True)
return path
# Backward-compatible wrapper used by older code/tests.
def _save_codex_credentials(token_data):
return _persist_codex_credentials(_get_active_hermes_home(), token_data)
# ── Codex protocol ──────────────────────────────────────────────────────────
def _json_request(url: str, payload: dict[str, Any], *, form: bool = False) -> dict[str, Any]:
if form:
data = urllib.parse.urlencode(payload).encode("utf-8")
content_type = "application/x-www-form-urlencoded"
else:
data = json.dumps(payload).encode("utf-8")
content_type = "application/json"
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={"Content-Type": content_type, "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
def _request_codex_user_code() -> dict[str, Any]:
return _json_request(CODEX_USER_CODE_URL, {"client_id": CODEX_CLIENT_ID})
def _poll_codex_authorization(device_auth_id: str, user_code: str) -> dict[str, Any] | None:
try:
return _json_request(
CODEX_DEVICE_TOKEN_URL,
{"device_auth_id": device_auth_id, "user_code": user_code},
)
except urllib.error.HTTPError as exc:
if exc.code in (403, 404):
return None
raise
def _exchange_codex_authorization(authorization_code: str, code_verifier: str) -> dict[str, Any]:
return _json_request(
CODEX_TOKEN_URL,
{
"grant_type": "authorization_code",
"code": authorization_code,
"redirect_uri": CODEX_REDIRECT_URI,
"client_id": CODEX_CLIENT_ID,
"code_verifier": code_verifier,
},
form=True,
)
def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
return {
"ok": True,
"provider": "openai-codex",
"flow_id": flow_id,
"status": flow.get("status", "pending"),
"verification_uri": CODEX_VERIFICATION_URI,
"user_code": flow.get("user_code", ""),
"expires_at": flow.get("expires_at"),
"poll_interval_seconds": flow.get("poll_interval_seconds", 5),
}
data = urllib.parse.urlencode(params).encode()
req = urllib.request.Request(CODEX_AUTH_URL, data=data, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
payload = {
"ok": True,
"provider": "openai-codex",
"flow_id": flow_id,
"status": flow.get("status", "error"),
}
if flow.get("status") == "error" and flow.get("error"):
payload["error"] = str(flow.get("error"))[:200]
return payload
def _drop_sensitive_flow_fields(flow: dict[str, Any]) -> None:
for key in (
"device_auth_id",
"authorization_code",
"code_verifier",
"access_token",
"refresh_token",
"token_data",
):
flow.pop(key, None)
def _cleanup_oauth_flows(now: float | None = None) -> None:
now = now or time.time()
cutoff = now - 300
with _OAUTH_FLOWS_LOCK:
for fid, flow in list(_OAUTH_FLOWS.items()):
status = flow.get("status")
if status == "pending" and float(flow.get("expires_at") or 0) <= now:
flow["status"] = "expired"
_drop_sensitive_flow_fields(flow)
if status in {"success", "expired", "cancelled", "error"} and float(flow.get("updated_at") or 0) < cutoff:
_OAUTH_FLOWS.pop(fid, None)
def _spawn_codex_oauth_worker(flow_id: str) -> None:
worker = threading.Thread(target=_run_codex_oauth_worker, args=(flow_id,), daemon=True)
worker.start()
def _set_flow_status(flow_id: str, status: str, **fields: Any) -> None:
with _OAUTH_FLOWS_LOCK:
flow = _OAUTH_FLOWS.get(flow_id)
if not flow:
return
flow["status"] = status
flow["updated_at"] = time.time()
flow.update(fields)
if status in {"success", "expired", "cancelled", "error"}:
_drop_sensitive_flow_fields(flow)
def _run_codex_oauth_worker(flow_id: str) -> None:
while True:
with _OAUTH_FLOWS_LOCK:
flow = dict(_OAUTH_FLOWS.get(flow_id) or {})
if not flow:
return
status = flow.get("status")
if status != "pending":
return
if float(flow.get("expires_at") or 0) <= time.time():
_set_flow_status(flow_id, "expired")
return
time.sleep(max(1, int(flow.get("poll_interval_seconds") or 5)))
with _OAUTH_FLOWS_LOCK:
live = dict(_OAUTH_FLOWS.get(flow_id) or {})
if live.get("status") != "pending":
return
try:
code_resp = _poll_codex_authorization(
str(live.get("device_auth_id") or ""),
str(live.get("user_code") or ""),
)
if code_resp is None:
continue
authorization_code = str(code_resp.get("authorization_code") or "").strip()
code_verifier = str(code_resp.get("code_verifier") or "").strip()
if not authorization_code or not code_verifier:
raise RuntimeError("Device auth response missing authorization_code or code_verifier")
tokens = _exchange_codex_authorization(authorization_code, code_verifier)
_persist_codex_credentials(Path(live["hermes_home"]), tokens)
_set_flow_status(flow_id, "success")
return
except Exception as exc:
logger.warning("Codex OAuth onboarding flow failed: %s", exc)
_set_flow_status(flow_id, "error", error=str(exc))
return
def start_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
"""Start the supported onboarding OAuth flow.
Currently v1 intentionally supports only OpenAI Codex. Other providers are
rejected instead of silently falling back to terminal-first setup.
"""
_cleanup_oauth_flows()
provider = str((body or {}).get("provider") or "").strip().lower()
if provider not in _ALLOWED_ONBOARDING_OAUTH_PROVIDERS:
if provider in _REJECTED_ONBOARDING_OAUTH_PROVIDERS or provider:
raise ValueError("Only OpenAI Codex OAuth is supported in WebUI onboarding right now")
raise ValueError("provider is required")
hermes_home = _get_active_hermes_home()
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode())
except Exception as e:
raise RuntimeError(f"Failed to start Codex OAuth: {e}") from e
device = _request_codex_user_code()
except Exception as exc:
raise RuntimeError(f"Failed to start Codex OAuth: {exc}") from exc
user_code = str(device.get("user_code") or "").strip()
device_auth_id = str(device.get("device_auth_id") or "").strip()
if not user_code or not device_auth_id:
raise RuntimeError("Device code response missing required fields")
interval = max(3, int(device.get("interval") or 5))
expires_in = int(device.get("expires_in") or CODEX_FLOW_MAX_WAIT_SECONDS)
expires_at = time.time() + min(max(expires_in, 60), CODEX_FLOW_MAX_WAIT_SECONDS)
flow_id = uuid.uuid4().hex
flow = {
"provider": "openai-codex",
"status": "pending",
"device_auth_id": device_auth_id,
"user_code": user_code,
"expires_at": expires_at,
"poll_interval_seconds": interval,
"hermes_home": str(hermes_home),
"created_at": time.time(),
"updated_at": time.time(),
}
with _OAUTH_FLOWS_LOCK:
_OAUTH_FLOWS[flow_id] = flow
_spawn_codex_oauth_worker(flow_id)
return _public_start_payload(flow_id, flow)
def poll_onboarding_oauth_flow(flow_id: str) -> dict[str, Any]:
_cleanup_oauth_flows()
fid = str(flow_id or "").strip()
if not fid:
raise ValueError("flow_id is required")
with _OAUTH_FLOWS_LOCK:
flow = _OAUTH_FLOWS.get(fid)
if not flow:
raise KeyError("OAuth flow not found")
if flow.get("status") == "pending" and float(flow.get("expires_at") or 0) <= time.time():
flow["status"] = "expired"
flow["updated_at"] = time.time()
_drop_sensitive_flow_fields(flow)
return _public_status_payload(fid, dict(flow))
def cancel_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
fid = str((body or {}).get("flow_id") or "").strip()
if not fid:
raise ValueError("flow_id is required")
with _OAUTH_FLOWS_LOCK:
flow = _OAUTH_FLOWS.get(fid)
if not flow:
return {"ok": True, "provider": "openai-codex", "flow_id": fid, "status": "cancelled"}
if flow.get("status") == "pending":
flow["status"] = "cancelled"
flow["updated_at"] = time.time()
_drop_sensitive_flow_fields(flow)
return _public_status_payload(fid, dict(flow))
# Backward-compatible names from the abandoned spike. They intentionally do not
# expose provider device secrets to callers anymore.
def start_codex_device_code():
return start_onboarding_oauth_flow({"provider": "openai-codex"})
def poll_codex_token(device_code, interval=5):
"""Poll for Codex OAuth token. Generator that yields status dicts.
Yields:
{"status": "polling", "attempt": N, "max_attempts": 40}
{"status": "success", "credentials": {...}}
{"status": "error", "error": "..."}
"""
params = {
"grant_type": CODEX_GRANT_TYPE_DEVICE,
"device_code": device_code,
"client_id": CODEX_CLIENT_ID,
}
data = urllib.parse.urlencode(params).encode()
max_attempts = 40 # 40 * 5 = 200s max
for attempt in range(max_attempts):
yield {"status": "polling", "attempt": attempt + 1, "max_attempts": max_attempts}
req = urllib.request.Request(CODEX_TOKEN_URL, data=data, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
token_data = json.loads(resp.read().decode())
# Save to auth.json credential_pool
_save_codex_credentials(token_data)
yield {"status": "success", "credentials": {
"access_token": "***",
"refresh_token": "***",
"token_type": token_data.get("token_type"),
"expires_in": token_data.get("expires_in"),
}}
return
except urllib.error.HTTPError as e:
body = e.read().decode()
try:
err_data = json.loads(body)
error = err_data.get("error", "")
if error == "authorization_pending":
time.sleep(interval)
continue
elif error == "slow_down":
time.sleep(interval + 5)
continue
elif error == "expired_token":
yield {"status": "error", "error": "Device code expired. Please try again."}
return
else:
yield {"status": "error", "error": err_data.get("error_description", error)}
return
except Exception:
yield {"status": "error", "error": body[:200]}
return
except Exception as e:
yield {"status": "error", "error": str(e)}
return
yield {"status": "error", "error": "OAuth flow timed out. Please try again."}
def _save_codex_credentials(token_data):
"""Save Codex OAuth credentials to auth.json credential_pool."""
auth = _read_auth_json()
if "credential_pool" not in auth:
auth["credential_pool"] = {}
pool = auth["credential_pool"]
if "openai-codex" not in pool:
pool["openai-codex"] = []
# Check if an oauth_device entry already exists (update in place)
updated = False
_now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
for entry in pool["openai-codex"]:
if entry.get("source") == "oauth_device":
entry["access_token"] = token_data.get("access_token", "")
entry["refresh_token"] = token_data.get("refresh_token", "")
entry["auth_type"] = "oauth"
entry["updated_at"] = _now_iso
updated = True
break
if not updated:
existing_ids = {e["id"] for e in pool.get("openai-codex", [])}
for _ in range(3): # retry on collision
cred_id = "codex-oauth-" + uuid.uuid4().hex[:8]
if cred_id not in existing_ids:
break
pool["openai-codex"].append({
"id": cred_id,
"label": "Codex OAuth",
"auth_type": "oauth",
"source": "oauth_device",
"access_token": token_data.get("access_token", ""),
"refresh_token": token_data.get("refresh_token", ""),
"priority": 1,
"created_at": _now_iso,
})
auth["updated_at"] = _now_iso
_write_auth_json(auth)
yield {"status": "error", "error": "Use /api/onboarding/oauth/poll with flow_id"}
+3 -2
View File
@@ -185,8 +185,9 @@ _PROVIDER_CATEGORIES = [
]
_UNSUPPORTED_PROVIDER_NOTE = (
"OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub "
"Copilot are still terminal-first. Use `hermes model` for those flows."
"Advanced provider flows such as Nous Portal and GitHub Copilot are still "
"terminal-first. OpenAI Codex can be authenticated in this onboarding flow "
"when your Hermes config selects the openai-codex provider."
)
+116 -38
View File
@@ -392,6 +392,7 @@ from api.config import (
set_reasoning_display,
set_reasoning_effort,
create_stream_channel,
get_webui_session_save_mode,
)
from api.helpers import (
require,
@@ -1252,6 +1253,11 @@ from api.onboarding import (
complete_onboarding,
probe_provider_endpoint,
)
from api.oauth import (
cancel_onboarding_oauth_flow,
poll_onboarding_oauth_flow,
start_onboarding_oauth_flow,
)
# Approval system (optional -- graceful fallback if agent not available)
try:
@@ -2279,38 +2285,19 @@ def handle_get(handler, parsed) -> bool:
return j(handler, {"error": "not found"}, status=404)
return _handle_clarify_inject(handler, parsed)
# ── OAuth (Codex device-code) ──
if parsed.path == "/api/oauth/codex/start":
"""Start Codex device-code OAuth flow. Returns user_code + verification_uri."""
try:
from api.oauth import start_codex_device_code
result = start_codex_device_code()
return j(handler, result)
except Exception as e:
return j(handler, {"error": str(e)}, status=500)
if parsed.path == "/api/oauth/codex/poll":
"""SSE endpoint for polling Codex OAuth token."""
if parsed.path == "/api/onboarding/oauth/poll":
qs = parse_qs(parsed.query)
device_code = qs.get("device_code", [""])[0]
if not device_code:
return j(handler, {"error": "device_code required"}, status=400)
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("Connection", "keep-alive")
handler.end_headers()
flow_id = qs.get("flow_id", [""])[0]
try:
from api.oauth import poll_codex_token
for event in poll_codex_token(device_code):
handler.wfile.write(f"data: {json.dumps(event)}\n\n".encode())
handler.wfile.flush()
if event.get("status") in ("success", "error"):
break
except Exception as e:
handler.wfile.write(f"data: {json.dumps({'status': 'error', 'error': str(e)})}\n\n".encode())
handler.wfile.flush()
return # SSE handled, no JSON response
return j(
handler,
poll_onboarding_oauth_flow(flow_id),
extra_headers={"Cache-Control": "no-store"},
)
except ValueError as e:
return bad(handler, str(e))
except KeyError as e:
return bad(handler, str(e), 404)
# ── Cron API (GET) ──
# All cron handlers touch cron.jobs which resolves HERMES_HOME from
@@ -3280,6 +3267,34 @@ def handle_post(handler, parsed) -> bool:
handler.wfile.write(response_body)
return True
if parsed.path == "/api/onboarding/oauth/start":
from api.auth import is_auth_enabled
import os as _os
if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"):
import ipaddress
try:
_xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip()
_xri = handler.headers.get("X-Real-IP", "").strip()
_raw = handler.client_address[0]
addr = ipaddress.ip_address(_xff or _xri or _raw)
is_local = addr.is_loopback or addr.is_private
except ValueError:
is_local = False
if not is_local:
return bad(handler, "Onboarding OAuth is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403)
try:
return j(handler, start_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"})
except ValueError as e:
return bad(handler, str(e))
except RuntimeError as e:
return bad(handler, str(e), 500)
if parsed.path == "/api/onboarding/oauth/cancel":
try:
return j(handler, cancel_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"})
except ValueError as e:
return bad(handler, str(e))
if parsed.path == "/api/onboarding/setup":
# Writing API keys to disk - restrict to local/private networks unless auth is active.
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
@@ -5056,6 +5071,68 @@ def _handle_background(handler, body):
return j(handler, {"task_id": task_id, "stream_id": stream_id, "session_id": bg.session_id})
def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, started_at: float | None) -> None:
"""Materialize the current user turn for eager first-turn persistence.
The streaming thread still receives ``pending_user_message`` so existing
cancel/recovery/final-merge paths keep their current contract. Eager mode
only adds a durable display-message checkpoint before the agent launches.
"""
if not msg:
return
existing = list(getattr(s, "messages", None) or [])
if existing:
latest = existing[-1]
if isinstance(latest, dict) and latest.get("role") == "user":
latest_text = " ".join(str(latest.get("content") or "").split())
msg_text = " ".join(str(msg or "").split())
if latest_text == msg_text:
return
user_msg = {"role": "user", "content": msg}
if isinstance(started_at, (int, float)) and started_at > 0:
user_msg["timestamp"] = int(started_at)
if attachments:
user_msg["attachments"] = list(attachments)
s.messages.append(user_msg)
def _prepare_chat_start_session_for_stream(
s,
*,
msg: str,
attachments,
workspace: str,
model: str,
model_provider,
stream_id: str,
started_at: float | None = None,
):
"""Persist chat-start state according to webui.session_save_mode.
``deferred`` keeps the existing sidecar/WAL-backed behaviour: save pending
fields but leave the display transcript empty until the agent merges the
result. ``eager`` additionally writes the current user turn into messages so
a process restart immediately after /api/chat/start preserves the prompt as
a normal session message. Empty sessions are never saved here because this
helper only runs after a non-empty message is validated.
"""
s.workspace = workspace
s.model = model
s.model_provider = model_provider
s.active_stream_id = stream_id
s.pending_user_message = msg
s.pending_attachments = attachments
s.pending_started_at = started_at if started_at is not None else time.time()
if get_webui_session_save_mode() == "eager":
_checkpoint_user_message_for_eager_session_save(
s,
msg,
attachments,
s.pending_started_at,
)
s.save()
def _handle_chat_start(handler, body):
try:
require(body, "session_id")
@@ -5103,14 +5180,15 @@ def _handle_chat_start(handler, body):
_clear_stale_stream_state(s)
stream_id = uuid.uuid4().hex
with _get_session_agent_lock(s.session_id):
s.workspace = workspace
s.model = model
s.model_provider = model_provider
s.active_stream_id = stream_id
s.pending_user_message = msg
s.pending_attachments = attachments
s.pending_started_at = time.time()
s.save()
_prepare_chat_start_session_for_stream(
s,
msg=msg,
attachments=attachments,
workspace=workspace,
model=model,
model_provider=model_provider,
stream_id=stream_id,
)
set_last_workspace(workspace)
stream = create_stream_channel()
with STREAMS_LOCK:
+53 -9
View File
@@ -1164,6 +1164,17 @@ def _find_current_user_turn(messages, msg_text):
return fallback
def _drop_checkpointed_current_user_from_context(messages, msg_text):
"""Return model history without an eager-checkpointed current user turn."""
history = list(messages or [])
if not history:
return history
current_user_key = _message_identity({'role': 'user', 'content': msg_text})
if current_user_key and _message_identity(history[-1]) == current_user_key:
return history[:-1]
return history
def _merge_display_messages_after_agent_result(previous_display, previous_context, result_messages, msg_text):
"""Keep UI transcript durable while allowing model context to compact.
@@ -1191,8 +1202,20 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
merged = previous_display[:]
seen = {_message_identity(m) for m in merged}
current_user_key = _message_identity({'role': 'user', 'content': msg_text})
for msg in candidates:
key = _message_identity(msg)
if (
key is not None
and key == current_user_key
and merged
and _message_identity(merged[-1]) == key
):
# Eager session-save mode can checkpoint the current user turn
# before the agent runs. When the agent returns that same user turn
# in result_messages, keep the durable checkpoint and append only
# the assistant/tool delta.
continue
if _is_context_compression_marker(msg) and key is not None and key in seen:
continue
merged.append(copy.deepcopy(msg))
@@ -1620,9 +1643,11 @@ def _run_agent_streaming(
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
# Throttle: emit metering events at most every 100 ms so the TPS label
# feels live during fast token streams without flooding the SSE channel.
# Throttle: emit metering events at most every 100 ms so the per-message
# TPS label feels live during fast token streams without flooding SSE.
_metering_last_emit = [time.monotonic() - 1] # fire immediately on first token
_metering_output_deltas = [0]
_metering_reasoning_deltas = [0]
def _emit_metering():
now = time.monotonic()
@@ -1631,6 +1656,8 @@ def _run_agent_streaming(
_metering_last_emit[0] = now
stats = meter().get_stats()
stats['session_id'] = stream_id
stats.setdefault('tps_available', False)
stats.setdefault('estimated', False)
put('metering', stats)
def on_token(text):
@@ -1642,8 +1669,11 @@ def _run_agent_streaming(
if stream_id in STREAM_PARTIAL_TEXT:
STREAM_PARTIAL_TEXT[stream_id] += str(text)
put('token', {'text': text})
# Update global throughput meter
meter().record_token(stream_id, len(STREAM_PARTIAL_TEXT[stream_id]))
# Update live throughput from stream delta callbacks, not from
# byte/character length. If a backend cannot provide live deltas,
# the frontend hides TPS rather than showing an estimate.
_metering_output_deltas[0] += 1
meter().record_token(stream_id, _metering_output_deltas[0])
_emit_metering()
def on_reasoning(text):
@@ -1655,8 +1685,9 @@ def _run_agent_streaming(
if stream_id in STREAM_REASONING_TEXT:
STREAM_REASONING_TEXT[stream_id] += str(text)
put('reasoning', {'text': str(text)})
# Track reasoning tokens in the meter so TPS reflects all AI output
meter().record_reasoning(stream_id, len(_reasoning_text))
# Track reasoning deltas in the meter so live TPS reflects all AI output.
_metering_reasoning_deltas[0] += 1
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
_emit_metering()
# Pre-initialise the activity counter here so on_tool (which
@@ -1690,7 +1721,8 @@ def _run_agent_streaming(
if stream_id in STREAM_REASONING_TEXT:
STREAM_REASONING_TEXT[stream_id] += str(reason_text)
put('reasoning', {'text': str(reason_text)})
meter().record_reasoning(stream_id, len(_reasoning_text))
_metering_reasoning_deltas[0] += 1
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
_emit_metering()
return
@@ -2059,7 +2091,10 @@ def _run_agent_streaming(
# Truthy-check covers None, missing-attr, and 0 uniformly.
_turn_started_at = _pending_started_at if _pending_started_at else time.time()
_previous_messages = list(s.messages or [])
_previous_context_messages = list(_session_context_messages(s))
_previous_context_messages = _drop_checkpointed_current_user_from_context(
_session_context_messages(s),
msg_text,
)
_pre_compression_count = getattr(
getattr(agent, 'context_compressor', None),
'compression_count', 0,
@@ -2459,10 +2494,15 @@ def _run_agent_streaming(
_turn_duration_seconds = max(0.0, time.time() - float(_turn_started_at))
except Exception:
_turn_duration_seconds = 0.0
_turn_tps = None
if output_tokens and _turn_duration_seconds > 0:
_turn_tps = round(float(output_tokens) / _turn_duration_seconds, 1)
if s.messages:
for _dm in reversed(s.messages):
if isinstance(_dm, dict) and _dm.get('role') == 'assistant':
_dm['_turnDuration'] = round(_turn_duration_seconds, 3)
if _turn_tps is not None:
_dm['_turnTps'] = _turn_tps
break
# Persist context window data on the session so the context-ring
# indicator survives a page reload (#1318). Must run BEFORE
@@ -2517,6 +2557,8 @@ def _run_agent_streaming(
'estimated_cost': estimated_cost,
'duration_seconds': round(_turn_duration_seconds, 3),
}
if _turn_tps is not None:
usage['tps'] = _turn_tps
# Include context window data from the agent's compressor for the UI indicator.
# The session-level persistence happens above (before s.save()) so the values
# survive a page reload; this block only populates the live SSE usage payload.
@@ -2567,9 +2609,11 @@ def _run_agent_streaming(
logger.debug("Failed to drain pending steer for session %s", session_id)
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
# Emit metering stats for the header TPS label
# Emit one last metering packet for the live message-header TPS label.
meter_stats = meter().get_stats()
meter_stats['session_id'] = session_id
meter_stats.setdefault('tps_available', False)
meter_stats.setdefault('estimated', False)
put('metering', meter_stats)
if _should_bg_title and _u0 and _a0:
threading.Thread(
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+2
View File
@@ -1238,6 +1238,7 @@ function applyBotName(){
_bootSettings=s;
window._sendKey=s.send_key||'enter';
window._showTokenUsage=!!s.show_token_usage;
window._showTps=!!s.show_tps;
window._showCliSessions=!!s.show_cli_sessions;
window._soundEnabled=!!s.sound_enabled;
window._notificationsEnabled=!!s.notifications_enabled;
@@ -1271,6 +1272,7 @@ function applyBotName(){
}catch(e){
window._sendKey='enter';
window._showTokenUsage=false;
window._showTps=false;
window._showCliSessions=false;
window._soundEnabled=false;
window._notificationsEnabled=false;
+7
View File
@@ -829,6 +829,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTps" style="width:15px;height:15px;accent-color:var(--accent)">
<span>Show token speed (TPS)</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays tokens per second in assistant message headers while streaming and after a response completes. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSimplifiedToolCalling" style="width:15px;height:15px;accent-color:var(--accent)">
+9 -8
View File
@@ -903,6 +903,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof d.usage.duration_seconds==='number'){
lastAsst._turnDuration=d.usage.duration_seconds;
}
if(typeof d.usage.tps==='number'&&d.usage.tps>0){
lastAsst._turnTps=d.usage.tps;
}
}
}
if(d.session.tool_calls&&d.session.tool_calls.length){
@@ -984,16 +987,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('metering',e=>{
// TPS + HIGH/LOW stats for the header chip — emitted at 1 Hz during a stream,
// silenced entirely when no sessions are active (ticker exits when idle).
try{
const d=JSON.parse(e.data||'{}');
const el=$('tpsStat');
if(!el) return;
const tps=typeof d.tps==='number'?d.tps.toFixed(1):'0.0';
const high=typeof d.high==='number' && d.high>=0?d.high.toFixed(1)+' high':'—';
const low=typeof d.low==='number' && d.low>=0?d.low.toFixed(1)+' low':'';
el.textContent=`${tps} t/s · ${high}${low?' · '+low:''}`;
if((d.session_id||activeSid)!==activeSid) return;
if(d.estimated===true||d.tps_available!==true||typeof d.tps!=='number'||d.tps<=0){
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null);
return;
}
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(d.tps);
}catch(_){}
});
+102 -73
View File
@@ -265,6 +265,9 @@ function _renderOnboardingBody(){
if(currentIsOauth){
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
const providerLabel=esc(currentProviderName);
const codexOauthPendingBody=currentProviderName==='openai-codex'
? 'This instance is configured to use <strong>openai-codex</strong>, which uses OAuth rather than an API key. Use the button below to authenticate with ChatGPT, then continue once provider status refreshes.'
: t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel);
if(isReady){
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
body.innerHTML=`
@@ -288,9 +291,10 @@ function _renderOnboardingBody(){
body.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-pending">
<div class="onboarding-oauth-icon">⚠</div>
<div>
<div style="flex:1">
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
<p>${codexOauthPendingBody}</p>
${currentProviderName==='openai-codex'?`<div style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="sm-btn" id="codexOAuthBtn" onclick="startCodexOAuth()" type="button">${t('oauth_login_codex')}</button></div><div id="codexOAuthFlow" style="display:none;margin-top:12px"></div>`:''}
</div>
</div>
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
@@ -314,17 +318,6 @@ function _renderOnboardingBody(){
${_renderOnboardingApiKeyField()}
${_renderOnboardingBaseUrlField(showBaseUrl)}
<p class="onboarding-copy">${keyHelp}</p>
<div class="onboarding-oauth-card" id="codexOAuthCard">
<div class="onboarding-oauth-icon">🔑</div>
<div style="flex:1">
<strong>${t('oauth_login_codex')}</strong>
<p style="margin:6px 0 0;font-size:13px;color:var(--muted);line-height:1.5">
${t('onboarding_oauth_switch_hint')}
</p>
</div>
<button class="sm-btn" id="codexOAuthBtn" onclick="startCodexOAuth()" style="margin-left:auto;flex-shrink:0">${t('oauth_login_codex')}</button>
</div>
<div id="codexOAuthFlow" style="display:none;margin-top:12px"></div>
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
return;
@@ -552,23 +545,100 @@ async function nextOnboardingStep(){
}
/* ── Codex OAuth device-code flow ── */
let _codexOAuthSSE=null;
let _codexOAuthPollTimer=null;
let _codexOAuthFlowId=null;
function _clearCodexOAuthPoll(){
if(_codexOAuthPollTimer){clearTimeout(_codexOAuthPollTimer);_codexOAuthPollTimer=null;}
}
function _setCodexOAuthButton(enabled){
const btn=$('codexOAuthBtn');
if(btn){btn.disabled=!enabled;btn.textContent=enabled?t('oauth_login_codex'):'...';}
}
async function copyCodexOAuthCode(code){
try{
await navigator.clipboard.writeText(code||'');
showToast('Code copied');
}catch(e){
showToast(code||'');
}
}
async function cancelCodexOAuth(){
const flowDiv=$('codexOAuthFlow');
const flowId=_codexOAuthFlowId;
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
if(flowId){
try{await api('/api/onboarding/oauth/cancel',{method:'POST',body:JSON.stringify({flow_id:flowId})});}catch(e){}
}
_setCodexOAuthButton(true);
if(flowDiv){
flowDiv.innerHTML=`<div class="onboarding-oauth-card"><div class="onboarding-oauth-icon">⏹</div><div><strong>OAuth login cancelled</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">Start again whenever you're ready.</p></div></div>`;
}
}
function _renderCodexOAuthTerminal(status,message){
const flowDiv=$('codexOAuthFlow');
if(!flowDiv)return;
const ok=status==='success';
const icon=ok?'✅':status==='expired'?'⌛':status==='cancelled'?'⏹':'❌';
const title=ok?t('oauth_codex_success'):(status==='expired'?t('oauth_codex_expired'):(status==='cancelled'?'OAuth login cancelled':t('oauth_codex_error')));
flowDiv.innerHTML=`
<div class="onboarding-oauth-card ${ok?'onboarding-oauth-ready':''}" ${ok?'':'style="border-color:var(--error,#e55)"'}>
<div class="onboarding-oauth-icon">${icon}</div>
<div><strong>${title}</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">${esc(message||'')}</p></div>
</div>`;
}
async function _pollCodexOAuth(){
const flowId=_codexOAuthFlowId;
if(!flowId)return;
try{
const resp=await api('/api/onboarding/oauth/poll?flow_id='+encodeURIComponent(flowId));
const status=(resp&&resp.status)||'error';
if(status==='pending'){
_codexOAuthPollTimer=setTimeout(_pollCodexOAuth,3000);
return;
}
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
_setCodexOAuthButton(true);
if(status==='success'){
_renderCodexOAuthTerminal('success','Credentials saved to the Hermes credential pool. Refreshing provider status…');
showToast(t('oauth_codex_success'));
try{await loadOnboardingWizard();}catch(e){}
}else if(status==='expired'){
_renderCodexOAuthTerminal('expired','The code expired. Start a new login flow to try again.');
}else if(status==='cancelled'){
_renderCodexOAuthTerminal('cancelled','The login flow was cancelled.');
}else{
_renderCodexOAuthTerminal('error',(resp&&resp.error)||'OAuth login failed. Please try again.');
}
}catch(e){
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
_setCodexOAuthButton(true);
_renderCodexOAuthTerminal('error',(e&&e.message)||String(e));
}
}
async function startCodexOAuth(){
const flowDiv=$('codexOAuthFlow');
const btn=$('codexOAuthBtn');
if(!flowDiv)return;
if(btn){btn.disabled=true;btn.textContent='...';}
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
_setCodexOAuthButton(false);
flowDiv.style.display='block';
flowDiv.innerHTML=`<div class="onboarding-oauth-card onboarding-oauth-pending"><div class="onboarding-oauth-icon">⏳</div><div><strong>${t('oauth_codex_polling')}</strong><p>Starting device-code flow…</p></div></div>`;
try{
const resp=await api('/api/oauth/codex/start',{method:'POST'});
const resp=await api('/api/onboarding/oauth/start',{method:'POST',body:JSON.stringify({provider:'openai-codex'})});
if(resp.error) throw new Error(resp.error);
const{device_code,user_code,verification_uri}=resp;
if(!device_code||!user_code||!verification_uri) throw new Error('Invalid OAuth response');
// Open verification URI in new tab
window.open(verification_uri,'_blank');
// Show user code prominently
const{flow_id,user_code,verification_uri}=resp;
if(!flow_id||!user_code||!verification_uri) throw new Error('Invalid OAuth response');
_codexOAuthFlowId=flow_id;
flowDiv.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-pending">
<div class="onboarding-oauth-icon">📋</div>
@@ -576,60 +646,19 @@ async function startCodexOAuth(){
<strong>${t('oauth_codex_step1')}</strong>
<p><a href="${esc(verification_uri)}" target="_blank" rel="noopener" style="color:var(--accent);word-break:break-all">${esc(verification_uri)}</a></p>
<p style="margin-top:8px"><strong>${t('oauth_codex_step2')}</strong></p>
<code style="display:inline-block;font-size:18px;letter-spacing:0.1em;background:rgba(255,255,255,.08);padding:6px 14px;border-radius:8px;margin-top:4px;user-select:all">${esc(user_code)}</code>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px">
<code style="display:inline-block;font-size:18px;letter-spacing:0.1em;background:rgba(255,255,255,.08);padding:6px 14px;border-radius:8px;user-select:all">${esc(user_code)}</code>
<button class="sm-btn" type="button" onclick="copyCodexOAuthCode('${esc(user_code)}')">Copy code</button>
<button class="sm-btn" type="button" onclick="cancelCodexOAuth()">Cancel</button>
</div>
<p style="margin-top:8px;color:var(--muted);font-size:13px">${t('oauth_codex_polling')}</p>
</div>
</div>`;
// Connect to SSE poll endpoint
const pollUrl=new URL('api/oauth/codex/poll?device_code='+encodeURIComponent(device_code),location.href);
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
_codexOAuthSSE=new EventSource(pollUrl.href);
_codexOAuthSSE.onmessage=function(ev){
let data;
try{data=JSON.parse(ev.data);}catch(e){return;}
if(data.status==='success'){
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
flowDiv.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-ready">
<div class="onboarding-oauth-icon">✅</div>
<div><strong>${t('oauth_codex_success')}</strong>
<p>Token saved to credential pool. You can now use Codex as a provider.</p></div>
</div>`;
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
showToast(t('oauth_codex_success'));
// Refresh onboarding status in background
loadOnboardingWizard().catch(()=>{});
}else if(data.status==='error'){
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
const isExpired=(data.error||'').includes('expired');
flowDiv.innerHTML=`
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
<div class="onboarding-oauth-icon">❌</div>
<div><strong>${isExpired?t('oauth_codex_expired'):t('oauth_codex_error')}</strong>
<p>${esc(data.error||'Unknown error')}</p></div>
</div>`;
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
}
// 'polling' status — keep waiting
};
_codexOAuthSSE.onerror=function(){
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
// Don't overwrite if already showing success/error
if(!flowDiv.querySelector('.onboarding-oauth-ready')&&!flowDiv.querySelector('[style*="error"]')){
flowDiv.innerHTML=`
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
<div class="onboarding-oauth-icon">❌</div>
<div><strong>${t('oauth_codex_error')}</strong><p>Connection lost. Please try again.</p></div>
</div>`;
}
};
_codexOAuthPollTimer=setTimeout(_pollCodexOAuth,Math.max(1000,Number(resp.poll_interval_seconds||3)*1000));
}catch(e){
flowDiv.innerHTML=`
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
<div class="onboarding-oauth-icon">❌</div>
<div><strong>${t('oauth_codex_error')}</strong><p>${esc(e.message||String(e))}</p></div>
</div>`;
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
_renderCodexOAuthTerminal('error',(e&&e.message)||String(e));
_setCodexOAuthButton(true);
}
}
+15 -3
View File
@@ -2922,6 +2922,8 @@ function _preferencesPayloadFromUi(){
if(langSel) payload.language=langSel.value;
const showUsageCb=$('settingsShowTokenUsage');
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
const showTpsCb=$('settingsShowTps');
if(showTpsCb) payload.show_tps=showTpsCb.checked;
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb) payload.simplified_tool_calling=simplifiedToolCb.checked;
const apiRedactCb=$('settingsApiRedact');
@@ -2988,6 +2990,11 @@ async function _autosavePreferencesSettings(payload){
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
if(typeof renderMessages==='function') renderMessages();
}
if(payload&&payload.show_tps!==undefined){
window._showTps=!!(saved&&saved.show_tps);
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
if(typeof renderMessages==='function') renderMessages();
}
_settingsPreferencesAutosaveRetryPayload=null;
_setPreferencesAutosaveStatus('saved');
// Only clear the global dirty flag and hide the unsaved-changes bar when
@@ -3128,6 +3135,8 @@ async function loadSettingsPanel(){
}
const showUsageCb=$('settingsShowTokenUsage');
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showTpsCb=$('settingsShowTps');
if(showTpsCb){showTpsCb.checked=!!settings.show_tps;showTpsCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const apiRedactCb=$('settingsApiRedact');
@@ -3562,9 +3571,10 @@ function _setSettingsAuthButtonsVisible(active){
}
function _applySavedSettingsUi(saved, body, opts){
const {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
const {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showTps=showTps;
window._showCliSessions=showCliSessions;
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
@@ -3652,6 +3662,7 @@ async function saveSettings(andClose){
const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'');
const sendKey=($('settingsSendKey')||{}).value;
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
const showTps=!!($('settingsShowTps')||{}).checked;
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const pw=($('settingsPassword')||{}).value;
const theme=($('settingsTheme')||{}).value||'dark';
@@ -3668,6 +3679,7 @@ async function saveSettings(andClose){
body.font_size=fontSize;
body.language=language;
body.show_token_usage=showTokenUsage;
body.show_tps=showTps;
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
body.show_cli_sessions=showCliSessions;
@@ -3693,7 +3705,7 @@ async function saveSettings(andClose){
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
}
}
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
_settingsDirty=false;
_resetSettingsPanelState();
@@ -3712,7 +3724,7 @@ async function saveSettings(andClose){
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
}
}
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
showToast(t('settings_saved'));
_settingsDirty=false;
_resetSettingsPanelState();
+14
View File
@@ -2665,6 +2665,20 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
.msg-role { font-size: 11px; font-weight: 500; margin-bottom: 6px; opacity: .8; letter-spacing: 0; }
.msg-role:hover { opacity: 1; }
.role-icon { width: 20px; height: 20px; font-size: 9px; }
.msg-tps-inline {
display: inline-flex;
align-items: center;
margin-left: 2px;
padding: 1px 6px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
background: var(--surface);
font-size: 10.5px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1.4;
}
.msg-time { opacity: .65; font-size: 10px; }
.msg-role:hover .msg-time { opacity: 1; }
+34 -6
View File
@@ -3179,15 +3179,43 @@ function _messageHasReasoningPayload(m){
if(Array.isArray(m.content)) return m.content.some(p=>p&&(p.type==='thinking'||p.type==='reasoning'));
return /<think>[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?<channel\|>|<\|turn\|>thinking\n[\s\S]*?<turn\|>/.test(String(m.content||''));
}
function _assistantRoleHtml(tsTitle=''){
const _bn=window._botName||'Hermes';
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span></div>`;
function _formatTurnTps(value){
const n=Number(value);
if(!Number.isFinite(n)||n<=0) return '';
const fixed=n>=100?Math.round(n).toLocaleString():n>=10?n.toFixed(1):n.toFixed(1);
return `${fixed} t/s`;
}
function _createAssistantTurn(tsTitle=''){
function isTpsDisplayEnabled(){
return window._showTps===true;
}
function _assistantRoleHtml(tsTitle='', tpsText=''){
const _bn=window._botName||'Hermes';
const tps=(isTpsDisplayEnabled()&&tpsText)?`<span class="msg-tps-inline" title="Tokens per second">${esc(tpsText)}</span>`:'';
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span>${tps}</div>`;
}
function _setAssistantTurnTps(turn, tpsText=''){
if(!turn) return;
const role=turn.querySelector('.msg-role.assistant');
if(!role) return;
let chip=role.querySelector('.msg-tps-inline');
const text=String(tpsText||'').trim();
if(!text){if(chip) chip.remove();return;}
if(!chip){
chip=document.createElement('span');
chip.className='msg-tps-inline';
chip.title='Tokens per second';
role.appendChild(chip);
}
chip.textContent=text;
}
function _setLiveAssistantTps(value){
_setAssistantTurnTps($('liveAssistantTurn'), isTpsDisplayEnabled()?_formatTurnTps(value):'');
}
function _createAssistantTurn(tsTitle='', tpsText=''){
const row=document.createElement('div');
row.className='msg-row assistant-turn';
row.dataset.role='assistant';
row.innerHTML=`${_assistantRoleHtml(tsTitle)}<div class="assistant-turn-blocks"></div>`;
row.innerHTML=`${_assistantRoleHtml(tsTitle, tpsText)}<div class="assistant-turn-blocks"></div>`;
return row;
}
function _assistantTurnBlocks(turn){
@@ -3837,7 +3865,7 @@ function renderMessages(){
}
if(!currentAssistantTurn){
currentAssistantTurn=_createAssistantTurn(tsTitle);
currentAssistantTurn=_createAssistantTurn(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');
inner.appendChild(currentAssistantTurn);
}
const seg=document.createElement('div');
+6 -5
View File
@@ -3,9 +3,9 @@
Mirrors the structure of test_1003_appearance_autosave.py to verify the
preferences-panel autosave pattern is wired correctly:
- All 13 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
- All 14 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
- Password field MUST still call _markSettingsDirty (security: never autosave)
- _preferencesPayloadFromUi covers all 13 fields
- _preferencesPayloadFromUi covers all 14 fields
- _setPreferencesAutosaveStatus uses the shared i18n keys
- Status div exists in static/index.html
- _autosavePreferencesSettings clears the dirty flag and hides the unsaved bar
@@ -38,6 +38,7 @@ PREFERENCE_FIELDS_AUTOSAVE = [
("settingsSendKey", "send_key"),
("settingsLanguage", "language"),
("settingsShowTokenUsage", "show_token_usage"),
("settingsShowTps", "show_tps"),
("settingsSimplifiedToolCalling", "simplified_tool_calling"),
("settingsShowCliSessions", "show_cli_sessions"),
("settingsSyncInsights", "sync_to_insights"),
@@ -51,8 +52,8 @@ PREFERENCE_FIELDS_AUTOSAVE = [
]
def test_all_13_preference_fields_have_autosave_payload_entries():
"""_preferencesPayloadFromUi must include all 13 preference fields."""
def test_all_14_preference_fields_have_autosave_payload_entries():
"""_preferencesPayloadFromUi must include all 14 preference fields."""
block = _function_block(PANELS_JS, "_preferencesPayloadFromUi")
for dom_id, field in PREFERENCE_FIELDS_AUTOSAVE:
assert f"$('{dom_id}')" in block, \
@@ -62,7 +63,7 @@ def test_all_13_preference_fields_have_autosave_payload_entries():
def test_preference_fields_use_schedule_autosave_not_mark_dirty():
"""All 12 listener attachments (excluding bot_name's debounce wrapper) must
"""All 13 listener attachments (excluding bot_name's debounce wrapper) must
use _schedulePreferencesAutosave. bot_name uses a wrapper but still
eventually calls _schedulePreferencesAutosave."""
panel = _load_settings_panel_block()
@@ -0,0 +1,182 @@
"""Regression tests for issue #1362 — Codex OAuth from onboarding."""
from __future__ import annotations
import json
import stat
import time
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll():
routes = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
get_idx = routes.find("def handle_get(")
post_idx = routes.find("def handle_post(")
assert get_idx != -1 and post_idx != -1
get_body = routes[get_idx:post_idx]
post_body = routes[post_idx:]
assert '"/api/onboarding/oauth/poll"' in get_body
assert '"/api/onboarding/oauth/start"' not in get_body
assert '"/api/oauth/codex/start"' not in routes
assert '"/api/oauth/codex/poll"' not in routes
assert '"/api/onboarding/oauth/start"' in post_body
assert '"/api/onboarding/oauth/cancel"' in post_body
def test_onboarding_oauth_rejects_non_codex_providers(monkeypatch):
import api.oauth as oauth
for provider in ("anthropic", "claude", "claude-code", "nous", "qwen-oauth", "copilot", "bogus"):
with pytest.raises(ValueError):
oauth.start_onboarding_oauth_flow({"provider": provider})
def test_start_payload_does_not_leak_provider_device_secrets(monkeypatch, tmp_path):
import api.oauth as oauth
oauth._OAUTH_FLOWS.clear()
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
monkeypatch.setattr(oauth, "_request_codex_user_code", lambda: {
"device_auth_id": "device-secret",
"user_code": "ABCD-EFGH",
"interval": 3,
})
monkeypatch.setattr(oauth, "_spawn_codex_oauth_worker", lambda flow_id: None)
payload = oauth.start_onboarding_oauth_flow({"provider": "openai-codex"})
assert payload["ok"] is True
assert payload["provider"] == "openai-codex"
assert payload["status"] == "pending"
assert payload["verification_uri"] == "https://auth.openai.com/codex/device"
assert payload["user_code"] == "ABCD-EFGH"
serialized = json.dumps(payload)
for forbidden in (
"device_auth_id",
"device-secret",
"authorization_code",
"code_verifier",
"access_token",
"refresh_token",
):
assert forbidden not in serialized
def test_poll_returns_high_level_status_only(monkeypatch, tmp_path):
import api.oauth as oauth
oauth._OAUTH_FLOWS.clear()
flow_id = "flow-test"
oauth._OAUTH_FLOWS[flow_id] = {
"provider": "openai-codex",
"status": "pending",
"device_auth_id": "device-secret",
"user_code": "ABCD-EFGH",
"code_verifier": "verifier-secret",
"authorization_code": "auth-secret",
"expires_at": time.time() + 60,
"poll_interval_seconds": 3,
"hermes_home": tmp_path,
}
payload = oauth.poll_onboarding_oauth_flow(flow_id)
assert payload == {"ok": True, "provider": "openai-codex", "flow_id": flow_id, "status": "pending"}
serialized = json.dumps(payload)
for forbidden in ("device_auth_id", "device-secret", "code_verifier", "authorization_code"):
assert forbidden not in serialized
def test_cancel_marks_flow_cancelled_and_poll_stops(tmp_path):
import api.oauth as oauth
oauth._OAUTH_FLOWS.clear()
flow_id = "flow-cancel"
oauth._OAUTH_FLOWS[flow_id] = {
"provider": "openai-codex",
"status": "pending",
"expires_at": time.time() + 60,
"hermes_home": tmp_path,
}
cancelled = oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
polled = oauth.poll_onboarding_oauth_flow(flow_id)
assert cancelled["status"] == "cancelled"
assert polled["status"] == "cancelled"
def test_expired_flow_reports_expired_and_drops_sensitive_lifecycle(tmp_path):
import api.oauth as oauth
oauth._OAUTH_FLOWS.clear()
flow_id = "flow-expired"
oauth._OAUTH_FLOWS[flow_id] = {
"provider": "openai-codex",
"status": "pending",
"device_auth_id": "device-secret",
"expires_at": time.time() - 1,
"hermes_home": tmp_path,
}
payload = oauth.poll_onboarding_oauth_flow(flow_id)
assert payload["status"] == "expired"
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "expired"
assert "device_auth_id" not in oauth._OAUTH_FLOWS[flow_id]
def test_codex_credentials_written_to_active_profile_auth_json(monkeypatch, tmp_path):
import api.oauth as oauth
from api.onboarding import _provider_oauth_authenticated
active_home = tmp_path / "active-profile"
realish_home = tmp_path / "process-home"
active_home.mkdir()
realish_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: realish_home)
auth_path = oauth._persist_codex_credentials(
active_home,
{"access_token": "access-secret", "refresh_token": "refresh-secret"},
)
assert auth_path == active_home / "auth.json"
assert auth_path.exists()
assert not (realish_home / ".hermes" / "auth.json").exists()
mode = stat.S_IMODE(auth_path.stat().st_mode)
assert mode == 0o600
store = json.loads(auth_path.read_text(encoding="utf-8"))
entry = store["credential_pool"]["openai-codex"][0]
assert entry["auth_type"] == "oauth"
assert entry["source"] == "manual:device_code"
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
assert _provider_oauth_authenticated("openai-codex", active_home) is True
def test_frontend_uses_onboarding_oauth_endpoints_and_no_secret_poll_url():
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
assert "/api/onboarding/oauth/start" in js
assert "/api/onboarding/oauth/poll" in js
assert "/api/onboarding/oauth/cancel" in js
assert "window.open(verification_uri" not in js
assert "device_code=" not in js
assert "device_code" not in js
assert "flow_id" in js
assert "copyCodexOAuthCode" in js
assert "cancelCodexOAuth" in js
def test_unsupported_note_no_longer_calls_openai_codex_terminal_first():
src = (REPO / "api" / "onboarding.py").read_text(encoding="utf-8")
start = src.find("_UNSUPPORTED_PROVIDER_NOTE")
body = src[start:start + 400]
assert "OpenAI Codex, and GitHub" not in body
assert "OpenAI Codex" in body and "authenticated in this onboarding flow" in body
assert "Anthropic" not in body
assert "Claude" not in body
+114
View File
@@ -0,0 +1,114 @@
"""Regression coverage for issue #1617: TPS belongs on message headers.
Product decision:
- show live TPS in the assistant message header while streaming when real TPS is available;
- persist/show the final TPS at the end of the turn;
- do not show placeholder or estimated TPS when unavailable.
"""
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
def test_tps_renders_in_message_header_not_global_titlebar():
assert "msg-tps-inline" in UI_JS, "assistant message headers need a TPS chip hook"
assert "msg-tps-inline" in CSS, "TPS header chip needs an explicit CSS hook"
assert "_assistantRoleHtml(tsTitle='', tpsText='')" in UI_JS, (
"assistant role/header rendering should accept the per-message TPS text"
)
assert "_formatTurnTps" in UI_JS, "TPS formatting should be centralized"
assert "_turnTps" in UI_JS, "settled assistant messages should render final TPS from message metadata"
assert "tpsStat" not in MESSAGES_JS, "live TPS must not target the removed/global titlebar chip"
def test_live_metering_updates_only_real_tps_and_never_placeholders():
listener_start = MESSAGES_JS.find("source.addEventListener('metering'")
assert listener_start != -1, "messages.js should listen for metering SSE events"
listener_end = MESSAGES_JS.find("source.addEventListener('apperror'", listener_start)
assert listener_end != -1, "apperror listener should follow metering listener"
listener = MESSAGES_JS[listener_start:listener_end]
assert "_setLiveAssistantTps" in listener, "live metering should update the live assistant header"
assert "tps_available" in listener and "estimated" in listener, (
"live TPS display must check availability and reject estimated readings"
)
assert "0.0 t/s" not in listener, "unavailable TPS should render nothing, not a 0.0 placeholder"
assert "'—'" not in listener and '"—"' not in listener, "unavailable TPS should render nothing, not a dash"
assert "high" not in listener.lower() and "low" not in listener.lower(), (
"message-header TPS should not carry global HIGH/LOW titlebar semantics"
)
def test_done_payload_persists_final_tps_when_exact_usage_available():
assert "usage['tps']" in STREAMING_PY, "done usage payload should include final exact TPS when available"
assert "output_tokens" in STREAMING_PY and "duration_seconds" in STREAMING_PY, (
"final TPS should be based on exact completion tokens over measured turn duration"
)
assert "d.usage.tps" in MESSAGES_JS, "done handler should read final TPS from the usage payload"
assert "lastAsst._turnTps" in MESSAGES_JS, "done handler should persist final TPS on the last assistant message"
def test_backend_marks_streaming_metering_availability_explicitly():
assert "tps_available" in STREAMING_PY, "metering SSE payloads must explicitly say whether TPS is displayable"
assert "estimated" in STREAMING_PY, "metering SSE payloads must explicitly distinguish estimated readings"
assert "record_token(stream_id, len(STREAM_PARTIAL_TEXT[stream_id]))" not in STREAMING_PY, (
"live TPS must not be derived from streamed character count / byte-size estimates"
)
def test_tps_display_setting_is_default_off_and_persisted():
assert '"show_tps": False' in CONFIG_PY, "TPS display should be disabled by default"
assert '"show_tps"' in CONFIG_PY and "_SETTINGS_BOOL_KEYS" in CONFIG_PY, (
"TPS display should be a persisted boolean WebUI setting"
)
assert "settingsShowTps" in INDEX_HTML, "Preferences needs a user-facing TPS display toggle"
assert "payload.show_tps=showTpsCb.checked" in PANELS_JS, (
"Preferences autosave should persist the TPS display toggle through /api/settings"
)
assert "showTpsCb.checked=!!settings.show_tps" in PANELS_JS, (
"Settings panel should hydrate the TPS toggle from persisted settings"
)
assert "window._showTps=!!s.show_tps" in BOOT_JS, (
"Boot should hydrate show_tps into a runtime flag"
)
assert "window._showTps=false" in BOOT_JS, (
"Boot fallback should keep TPS hidden when settings cannot load"
)
def test_tps_display_hot_applies_when_preferences_autosave():
fn_start = PANELS_JS.find("async function _autosavePreferencesSettings")
assert fn_start != -1, "preferences autosave function should exist"
fn_end = PANELS_JS.find("function _retryPreferencesAutosave", fn_start)
assert fn_end != -1, "retry function should follow preferences autosave"
fn = PANELS_JS[fn_start:fn_end]
assert "payload&&payload.show_tps!==undefined" in fn, (
"TPS preference autosave must detect the show_tps field specifically"
)
assert "window._showTps=!!(saved&&saved.show_tps)" in fn, (
"TPS preference autosave should update the runtime flag from the saved response"
)
assert "clearMessageRenderCache" in fn and "renderMessages" in fn, (
"TPS preference autosave should re-render the open transcript without refresh"
)
def test_tps_header_rendering_respects_display_setting():
assert "function isTpsDisplayEnabled()" in UI_JS, "TPS visibility should be centralized"
assert "return window._showTps===true" in UI_JS, "TPS should only render when explicitly enabled"
assert "const tps=(isTpsDisplayEnabled()&&tpsText)" in UI_JS, (
"settled assistant headers must suppress TPS when the setting is off"
)
assert "isTpsDisplayEnabled()?_formatTurnTps(value):''" in UI_JS, (
"live TPS updates must remove/suppress the chip when the setting is off"
)
assert "isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):''" in UI_JS, (
"reloaded assistant messages must not render persisted TPS while disabled"
)
@@ -38,7 +38,7 @@ def test_streaming_persists_context_fields_on_session_before_save():
# Save call follows shortly after
save_call = src.find("\n s.save()", block_start)
assert save_call != -1, "s.save() not found after the post-merge marker"
assert save_call - block_start < 3000, (
assert save_call - block_start < 3400, (
"s.save() should be close to the post-merge marker — block expanded unexpectedly. "
"If you've added a new pre-save mutation block here, bump this limit."
)
+132
View File
@@ -0,0 +1,132 @@
"""Regression tests for config-driven first-turn session persistence (#1406)."""
import json
import pytest
import api.config as config
import api.models as models
import api.routes as routes
import api.streaming as streaming
from api.models import Session, new_session
@pytest.fixture(autouse=True)
def _isolate_state(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
monkeypatch.setattr(config, "SESSION_INDEX_FILE", index_file, raising=False)
models.SESSIONS.clear()
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.SESSION_AGENT_LOCKS.clear()
monkeypatch.setattr(config, "cfg", {})
monkeypatch.setattr(config, "_cfg_cache", {})
yield session_dir
models.SESSIONS.clear()
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.SESSION_AGENT_LOCKS.clear()
def test_session_save_mode_defaults_to_deferred_for_missing_config():
assert config.get_webui_session_save_mode({}) == "deferred"
assert config.get_webui_session_save_mode({"webui": {}}) == "deferred"
@pytest.mark.parametrize("raw", ["bogus", "", None, 42, {"mode": "eager"}])
def test_invalid_session_save_mode_falls_back_to_deferred(raw):
assert config.get_webui_session_save_mode({"webui": {"session_save_mode": raw}}) == "deferred"
def test_eager_session_save_mode_is_accepted():
assert config.get_webui_session_save_mode({"webui": {"session_save_mode": "eager"}}) == "eager"
def test_eager_mode_still_does_not_save_empty_new_sessions(_isolate_state, monkeypatch):
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "eager"}})
s = new_session()
assert not s.path.exists(), "eager mode must not recreate empty Untitled session files"
def test_deferred_chat_start_persists_pending_only_before_thread(_isolate_state, monkeypatch):
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "deferred"}})
s = new_session(workspace=str(_isolate_state.parent))
routes._prepare_chat_start_session_for_stream(
s,
msg="hello deferred",
attachments=[],
workspace=str(_isolate_state.parent),
model=s.model,
model_provider=s.model_provider,
stream_id="stream_deferred",
started_at=123.0,
)
on_disk = json.loads(s.path.read_text(encoding="utf-8"))
assert on_disk["messages"] == []
assert on_disk["pending_user_message"] == "hello deferred"
def test_eager_chat_start_checkpoints_first_user_message_before_thread(_isolate_state, monkeypatch):
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "eager"}})
s = new_session(workspace=str(_isolate_state.parent))
routes._prepare_chat_start_session_for_stream(
s,
msg="hello eager",
attachments=[{"name": "note.txt", "path": "", "mime": "text/plain"}],
workspace=str(_isolate_state.parent),
model=s.model,
model_provider=s.model_provider,
stream_id="stream_eager",
started_at=456.0,
)
on_disk = json.loads(s.path.read_text(encoding="utf-8"))
assert [m["role"] for m in on_disk["messages"]] == ["user"]
assert on_disk["messages"][0]["content"] == "hello eager"
assert on_disk["messages"][0]["attachments"][0]["name"] == "note.txt"
assert on_disk["pending_user_message"] == "hello eager"
def test_eager_wal_repair_does_not_duplicate_checkpointed_user_message(_isolate_state, monkeypatch):
s = Session(session_id="eager_repair", messages=[{"role": "user", "content": "survive"}])
s.pending_user_message = "survive"
s.active_stream_id = "dead_stream"
s.pending_started_at = 789.0
s.save()
repaired = models._repair_stale_pending(s)
assert repaired is True
user_messages = [m for m in s.messages if m.get("role") == "user" and m.get("content") == "survive"]
assert len(user_messages) == 1
assert s.pending_user_message is None
assert any(m.get("_error") for m in s.messages if m.get("role") == "assistant")
def test_eager_checkpointed_user_is_removed_from_model_context():
context = streaming._drop_checkpointed_current_user_from_context(
[
{"role": "user", "content": "older"},
{"role": "assistant", "content": "prior"},
{"role": "user", "content": "current"},
],
"current",
)
assert [m["content"] for m in context] == ["older", "prior"]
def test_eager_checkpointed_user_is_not_duplicated_after_agent_result():
merged = streaming._merge_display_messages_after_agent_result(
previous_display=[{"role": "user", "content": "repeat me"}],
previous_context=[],
result_messages=[
{"role": "user", "content": "repeat me"},
{"role": "assistant", "content": "ok"},
],
msg_text="repeat me",
)
assert [m["role"] for m in merged] == ["user", "assistant"]