diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a7373a..e46b02c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/ROADMAP.md b/ROADMAP.md index 4696cf53..54a10f45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/TESTING.md b/TESTING.md index ab0cbb1d..eeeb72d1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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: /* diff --git a/api/config.py b/api/config.py index adb4670a..2ebabc5f 100644 --- a/api/config.py +++ b/api/config.py @@ -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", diff --git a/api/metering.py b/api/metering.py index 6edf2961..c4696d24 100644 --- a/api/metering.py +++ b/api/metering.py @@ -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), } diff --git a/api/models.py b/api/models.py index 867beb37..33e736b2 100644 --- a/api/models.py +++ b/api/models.py @@ -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 = [] diff --git a/api/oauth.py b/api/oauth.py index 4cf4f180..621a4814 100644 --- a/api/oauth.py +++ b/api/oauth.py @@ -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"} diff --git a/api/onboarding.py b/api/onboarding.py index b572c542..af9d82ce 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -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." ) diff --git a/api/routes.py b/api/routes.py index 47a2b12e..8ef98b63 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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: diff --git a/api/streaming.py b/api/streaming.py index 1ff71227..97d79afc 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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( diff --git a/docs/pr-media/1362/codex-oauth-onboarding.png b/docs/pr-media/1362/codex-oauth-onboarding.png new file mode 100644 index 00000000..9d184151 Binary files /dev/null and b/docs/pr-media/1362/codex-oauth-onboarding.png differ diff --git a/docs/pr-media/1406/eager-config-app-shell.png b/docs/pr-media/1406/eager-config-app-shell.png new file mode 100644 index 00000000..871741ea Binary files /dev/null and b/docs/pr-media/1406/eager-config-app-shell.png differ diff --git a/docs/pr-media/1640/tps-toggle-off-default.png b/docs/pr-media/1640/tps-toggle-off-default.png new file mode 100644 index 00000000..ffefd438 Binary files /dev/null and b/docs/pr-media/1640/tps-toggle-off-default.png differ diff --git a/docs/pr-media/1640/tps-toggle-on-hot-apply.png b/docs/pr-media/1640/tps-toggle-on-hot-apply.png new file mode 100644 index 00000000..eb54b50a Binary files /dev/null and b/docs/pr-media/1640/tps-toggle-on-hot-apply.png differ diff --git a/docs/pr-media/1640/tps-toggle-settings.png b/docs/pr-media/1640/tps-toggle-settings.png new file mode 100644 index 00000000..c8086deb Binary files /dev/null and b/docs/pr-media/1640/tps-toggle-settings.png differ diff --git a/docs/pr-media/issue-1617/after.png b/docs/pr-media/issue-1617/after.png new file mode 100644 index 00000000..0d18929e Binary files /dev/null and b/docs/pr-media/issue-1617/after.png differ diff --git a/docs/pr-media/issue-1617/before.png b/docs/pr-media/issue-1617/before.png new file mode 100644 index 00000000..85686efc Binary files /dev/null and b/docs/pr-media/issue-1617/before.png differ diff --git a/static/boot.js b/static/boot.js index 598fcc57..d4e8bdd0 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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; diff --git a/static/index.html b/static/index.html index bcacc357..29061844 100644 --- a/static/index.html +++ b/static/index.html @@ -829,6 +829,13 @@
Displays input/output token count below each assistant reply. Also toggled with /usage.
+
+ +
Displays tokens per second in assistant message headers while streaming and after a response completes. Off by default.
+