Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59de540b3d | ||
|
|
2e1aa3c99c | ||
|
|
ce9adc5e2c |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -3,6 +3,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.320] — 2026-06-07 — Release KJ (Phase 2 — Polish (pl) language support)
|
||||
|
||||
### Added
|
||||
- **Polish (pl) language support.** Adds a complete Polish translation set to the in-app localization, a Polish login-page locale, and resolver coverage for `pl` / `pl-PL` / `pl_PL`, with the locale registered consistently across every key group (appearance skins, approval/clarify prompts, tooltips, cache-usage labels, profile skill counts) and parity tests asserting the new locale is complete and actually translated. (#3781, @leszek3737)
|
||||
|
||||
## [v0.51.319] — 2026-06-07 — Release KI (Phase 3 light — refresh stale continuation metadata)
|
||||
|
||||
### Fixed
|
||||
- **A compression continuation no longer looks like it lost recent messages when `_index.json` is stale.** Complementing the snapshot-side fix, the sidebar now also refreshes a continuation row's sidecar metadata when the row is part of a compression lineage and its sidecar file is newer than the indexed timestamp — so recent turns that landed in the sidecar after the last index write are reflected in the row's count/last-activity instead of showing a stale (lower) message count. The refresh stays scoped to lineage-shaped rows, so ordinary sidebar polls don't hydrate every historical transcript. (#3789, refs #3740, @ai-ag2026)
|
||||
|
||||
## [v0.51.318] — 2026-06-07 — Release KH (Phase 3 light — warm account-usage probe worker pool)
|
||||
|
||||
### Changed
|
||||
- **Account-usage quota probes now reuse a warm worker pool instead of spawning a fresh interpreter per probe.** Quota checks for the providers that need an isolated subprocess previously started a new Python interpreter — and re-imported the agent account-usage stack — on every uncached probe, which is slow when the quota UI is opened across several profiles/providers. A profile-home-keyed pool of persistent warm workers (with a 5-minute idle shutdown) eliminates the repeated spawn/import overhead while keeping every existing safety property: the concurrency semaphore cap, the LRU result cache, the parent-death-signal hardening, and subprocess env scrubbing. Workers are invalidated when provider credentials change so a child process can't retain stale keys, contention falls back to a one-shot probe, and all workers are cleaned up at process exit. (#3722 / #1912, @rodboev)
|
||||
|
||||
## [v0.51.317] — 2026-06-07 — Release KG (Phase 3 light — align CSP enforcement with report policy)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2610,11 +2610,27 @@ def _row_may_need_sidecar_metadata_refresh(
|
||||
)
|
||||
if is_runtime_row:
|
||||
return True
|
||||
sid = str(session.get('session_id') or '')
|
||||
if not session.get('pre_compression_snapshot'):
|
||||
return False
|
||||
# Refresh a stale-indexed COMPRESSION CONTINUATION row from its sidecar.
|
||||
# Gate tightly: a plain /branch fork also carries parent_session_id
|
||||
# (#1342) but has no compression sidecar drift to correct, and its file
|
||||
# mtime routinely exceeds the indexed logical last_message_at — so
|
||||
# including forks here would call load_metadata_only() on every fork row
|
||||
# on every /api/sessions poll (the molasses #3770 guards against, per the
|
||||
# #3789 release gate). Exclude session_source == 'fork'
|
||||
# (the marker /api/session/branch stamps; see _is_continuation_session)
|
||||
# so only true continuations are eligible.
|
||||
if str(session.get('session_source') or '').strip().lower() == 'fork':
|
||||
return False
|
||||
lineage_shaped = bool(
|
||||
session.get('parent_session_id')
|
||||
or session.get('_lineage_root_id')
|
||||
or session.get('_compression_segment_count')
|
||||
)
|
||||
return bool(lineage_shaped and sid and _sidecar_mtime_after_index_timestamp(session))
|
||||
if session.get('message_count') is None or session.get('last_message_at') is None:
|
||||
return True
|
||||
sid = str(session.get('session_id') or '')
|
||||
return bool(sid and stale_snapshot_ids and sid in stale_snapshot_ids)
|
||||
|
||||
|
||||
|
||||
380
api/providers.py
380
api/providers.py
@@ -7,6 +7,7 @@ multi-provider support).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
@@ -75,6 +76,7 @@ _PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0
|
||||
_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS = 35.0
|
||||
_ACCOUNT_USAGE_CACHE_TTL_SECONDS = 45.0
|
||||
_ACCOUNT_USAGE_CACHE_MAX_ENTRIES = 64
|
||||
_ACCOUNT_USAGE_WORKER_IDLE_SECONDS = 5 * 60
|
||||
_ACCOUNT_USAGE_PROVIDERS = frozenset({"openai-codex", "anthropic"})
|
||||
|
||||
# Upper bound on simultaneous profile-isolated quota probe subprocesses.
|
||||
@@ -129,6 +131,8 @@ _account_usage_probe_semaphore: threading.BoundedSemaphore | None = None
|
||||
# represented as non-None snapshots and remain cacheable.
|
||||
_account_usage_status_cache: dict[tuple[str, str, str], tuple[float, Any]] = {}
|
||||
_account_usage_status_cache_lock = threading.Lock()
|
||||
_account_usage_worker_pool: dict[str, "_AccountUsageProbeWorker"] = {}
|
||||
_account_usage_worker_pool_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_account_usage_probe_semaphore() -> threading.BoundedSemaphore:
|
||||
@@ -146,7 +150,7 @@ def _get_account_usage_probe_semaphore() -> threading.BoundedSemaphore:
|
||||
# code (_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP) also covers the grandchild
|
||||
# fork inside the child, but this preexec_fn handles the direct child-process
|
||||
# case. Returns None on non-POSIX or when prctl is unavailable so that
|
||||
# subprocess.run() works on Windows/macOS without changes.
|
||||
# subprocess startup works on Windows/macOS without changes.
|
||||
def _account_usage_preexec_fn() -> None:
|
||||
try:
|
||||
import ctypes
|
||||
@@ -159,6 +163,7 @@ def _account_usage_preexec_fn() -> None:
|
||||
_ACCOUNT_USAGE_SUBPROCESS_CODE = r"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -627,17 +632,48 @@ def _fetch_codex_account_usage_from_pool():
|
||||
return None
|
||||
|
||||
|
||||
provider = sys.argv[1]
|
||||
api_key = sys.argv[2] or None
|
||||
try:
|
||||
snapshot = fetch_account_usage(provider, api_key=api_key)
|
||||
except Exception:
|
||||
snapshot = None
|
||||
if str(provider or "").strip().lower() == "openai-codex":
|
||||
pool_snapshot = _fetch_codex_account_usage_from_pool()
|
||||
if isinstance(getattr(pool_snapshot, "pool", None), dict):
|
||||
snapshot = pool_snapshot
|
||||
print(json.dumps(_snapshot_payload(snapshot)))
|
||||
def _fetch_snapshot(provider, api_key, env_var=None):
|
||||
previous = os.environ.get(env_var) if env_var else None
|
||||
had_previous = bool(env_var and env_var in os.environ)
|
||||
if env_var and api_key:
|
||||
os.environ[env_var] = api_key
|
||||
try:
|
||||
try:
|
||||
snapshot = fetch_account_usage(provider, api_key=api_key)
|
||||
except Exception:
|
||||
snapshot = None
|
||||
if str(provider or "").strip().lower() == "openai-codex":
|
||||
pool_snapshot = _fetch_codex_account_usage_from_pool()
|
||||
if isinstance(getattr(pool_snapshot, "pool", None), dict):
|
||||
snapshot = pool_snapshot
|
||||
return _snapshot_payload(snapshot)
|
||||
finally:
|
||||
if env_var and api_key:
|
||||
if had_previous:
|
||||
os.environ[env_var] = previous
|
||||
else:
|
||||
os.environ.pop(env_var, None)
|
||||
|
||||
|
||||
def _run_worker():
|
||||
for raw_line in sys.stdin:
|
||||
try:
|
||||
request = json.loads(raw_line)
|
||||
provider = request.get("provider")
|
||||
api_key = request.get("api_key") or None
|
||||
env_var = request.get("env_var") or None
|
||||
payload = _fetch_snapshot(provider, api_key, env_var=env_var)
|
||||
except Exception:
|
||||
payload = None
|
||||
print(json.dumps(payload), flush=True)
|
||||
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--worker":
|
||||
_run_worker()
|
||||
else:
|
||||
provider = sys.argv[1]
|
||||
api_key = sys.argv[2] or None
|
||||
print(json.dumps(_fetch_snapshot(provider, api_key)), flush=True)
|
||||
"""
|
||||
|
||||
# SECTION: Provider ↔ env var mapping
|
||||
@@ -1186,6 +1222,267 @@ def _account_usage_payload_to_snapshot(payload: Any) -> Any:
|
||||
)
|
||||
|
||||
|
||||
class _AccountUsageProbeWorker:
|
||||
def __init__(self, home: Path):
|
||||
self.home = Path(home)
|
||||
self.last_used = time.monotonic()
|
||||
self._lock = threading.RLock()
|
||||
self._proc: subprocess.Popen[str] | None = None
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
self._close_process(proc)
|
||||
|
||||
@staticmethod
|
||||
def _close_process(proc: subprocess.Popen[str] | None) -> None:
|
||||
if proc is None:
|
||||
return
|
||||
for stream_name in ("stdin", "stdout"):
|
||||
stream = getattr(proc, stream_name, None)
|
||||
try:
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=1.0)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def fetch(self, provider: str, *, api_key: str | None = None) -> Any:
|
||||
if not self._lock.acquire(blocking=False):
|
||||
return _fetch_account_usage_once_for_home(provider, self.home, api_key=api_key)
|
||||
try:
|
||||
return self._fetch_locked(provider, api_key=api_key)
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def _fetch_locked(self, provider: str, *, api_key: str | None = None) -> Any:
|
||||
self.last_used = time.monotonic()
|
||||
proc = self._ensure_process(provider)
|
||||
if proc is None or proc.stdin is None or proc.stdout is None:
|
||||
return None
|
||||
|
||||
request = json.dumps({
|
||||
"provider": provider,
|
||||
"api_key": api_key or "",
|
||||
"env_var": _provider_env_var_for((provider or "").strip().lower()),
|
||||
}) + "\n"
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
def round_trip() -> None:
|
||||
try:
|
||||
proc.stdin.write(request)
|
||||
proc.stdin.flush()
|
||||
result["line"] = proc.stdout.readline()
|
||||
except Exception as exc:
|
||||
result["error"] = exc
|
||||
|
||||
thread = threading.Thread(target=round_trip, daemon=True)
|
||||
thread.start()
|
||||
thread.join(_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS)
|
||||
self.last_used = time.monotonic()
|
||||
if thread.is_alive():
|
||||
self.close()
|
||||
thread.join(timeout=1.0)
|
||||
logger.debug("Account usage worker for %s timed out", provider)
|
||||
return None
|
||||
if result.get("error") is not None:
|
||||
exc = result["error"]
|
||||
self.close()
|
||||
logger.debug(
|
||||
"Account usage worker for %s failed",
|
||||
provider,
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
return None
|
||||
|
||||
line = str(result.get("line") or "").strip()
|
||||
if not line:
|
||||
self.close()
|
||||
logger.debug("Account usage worker for %s exited before responding", provider)
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
self.close()
|
||||
logger.debug("Account usage worker for %s returned invalid JSON", provider)
|
||||
return None
|
||||
return _account_usage_payload_to_snapshot(payload)
|
||||
|
||||
def _ensure_process(self, provider: str) -> subprocess.Popen[str] | None:
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
return self._proc
|
||||
old_proc = self._proc
|
||||
self._proc = None
|
||||
self._close_process(old_proc)
|
||||
try:
|
||||
from api.config import PYTHON_EXE
|
||||
except Exception:
|
||||
PYTHON_EXE = sys.executable or "python3"
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"stdin": subprocess.PIPE,
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"text": True,
|
||||
"bufsize": 1,
|
||||
}
|
||||
if hasattr(os, "fork"): # POSIX
|
||||
kwargs["preexec_fn"] = _account_usage_preexec_fn
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
[
|
||||
PYTHON_EXE,
|
||||
"-c",
|
||||
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
|
||||
"--worker",
|
||||
],
|
||||
env=_account_usage_subprocess_env(self.home, provider, None),
|
||||
**kwargs,
|
||||
)
|
||||
except Exception:
|
||||
self._proc = None
|
||||
logger.debug("Account usage worker for %s failed to launch", provider, exc_info=True)
|
||||
return self._proc
|
||||
|
||||
|
||||
def _launch_account_usage_worker_process(
|
||||
home: Path,
|
||||
provider: str,
|
||||
*,
|
||||
stdin: Any = subprocess.PIPE,
|
||||
stdout: Any = subprocess.PIPE,
|
||||
) -> subprocess.Popen[str] | None:
|
||||
try:
|
||||
from api.config import PYTHON_EXE
|
||||
except Exception:
|
||||
PYTHON_EXE = sys.executable or "python3"
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"stdin": stdin,
|
||||
"stdout": stdout,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"text": True,
|
||||
"bufsize": 1,
|
||||
}
|
||||
if hasattr(os, "fork"): # POSIX
|
||||
kwargs["preexec_fn"] = _account_usage_preexec_fn
|
||||
|
||||
try:
|
||||
return subprocess.Popen(
|
||||
[
|
||||
PYTHON_EXE,
|
||||
"-c",
|
||||
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
|
||||
"--worker",
|
||||
],
|
||||
env=_account_usage_subprocess_env(home, provider, None),
|
||||
**kwargs,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Account usage worker for %s failed to launch", provider, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_account_usage_once_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any:
|
||||
proc = _launch_account_usage_worker_process(Path(home), provider)
|
||||
if proc is None or proc.stdin is None or proc.stdout is None:
|
||||
_AccountUsageProbeWorker._close_process(proc)
|
||||
return None
|
||||
request = json.dumps({
|
||||
"provider": provider,
|
||||
"api_key": api_key or "",
|
||||
"env_var": _provider_env_var_for((provider or "").strip().lower()),
|
||||
}) + "\n"
|
||||
try:
|
||||
stdout, _stderr = proc.communicate(request, timeout=_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
_AccountUsageProbeWorker._close_process(proc)
|
||||
return None
|
||||
except Exception:
|
||||
_AccountUsageProbeWorker._close_process(proc)
|
||||
return None
|
||||
try:
|
||||
line = str(stdout or "").splitlines()[0]
|
||||
payload = json.loads(line.strip())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
except IndexError:
|
||||
return None
|
||||
return _account_usage_payload_to_snapshot(payload)
|
||||
|
||||
|
||||
def _get_account_usage_probe_worker(home: Path) -> _AccountUsageProbeWorker:
|
||||
key = str(Path(home))
|
||||
with _account_usage_worker_pool_lock:
|
||||
worker = _account_usage_worker_pool.get(key)
|
||||
if worker is None:
|
||||
worker = _AccountUsageProbeWorker(Path(home))
|
||||
_account_usage_worker_pool[key] = worker
|
||||
return worker
|
||||
|
||||
|
||||
def _cleanup_account_usage_probe_workers(
|
||||
*,
|
||||
now: float | None = None,
|
||||
idle_seconds: float = _ACCOUNT_USAGE_WORKER_IDLE_SECONDS,
|
||||
) -> None:
|
||||
cutoff = time.monotonic() if now is None else now
|
||||
stale: list[tuple[str, _AccountUsageProbeWorker]] = []
|
||||
with _account_usage_worker_pool_lock:
|
||||
for key, worker in list(_account_usage_worker_pool.items()):
|
||||
if worker._lock.acquire(blocking=False):
|
||||
try:
|
||||
proc = worker._proc
|
||||
is_dead = proc is None or proc.poll() is not None
|
||||
if is_dead or cutoff - worker.last_used >= idle_seconds:
|
||||
stale.append((key, worker))
|
||||
_account_usage_worker_pool.pop(key, None)
|
||||
finally:
|
||||
worker._lock.release()
|
||||
for _key, worker in stale:
|
||||
worker.close()
|
||||
|
||||
|
||||
def _close_account_usage_probe_workers() -> None:
|
||||
with _account_usage_worker_pool_lock:
|
||||
workers = list(_account_usage_worker_pool.values())
|
||||
_account_usage_worker_pool.clear()
|
||||
_close_account_usage_probe_worker_list(workers)
|
||||
|
||||
|
||||
def _close_account_usage_probe_worker_list(workers: list[_AccountUsageProbeWorker]) -> None:
|
||||
for worker in workers:
|
||||
worker.close()
|
||||
|
||||
|
||||
def _close_account_usage_probe_workers_async() -> None:
|
||||
with _account_usage_worker_pool_lock:
|
||||
workers = list(_account_usage_worker_pool.values())
|
||||
_account_usage_worker_pool.clear()
|
||||
if not workers:
|
||||
return
|
||||
thread = threading.Thread(
|
||||
target=_close_account_usage_probe_worker_list,
|
||||
args=(workers,),
|
||||
daemon=True,
|
||||
name="account-usage-worker-close",
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
atexit.register(_close_account_usage_probe_workers)
|
||||
|
||||
|
||||
def _account_usage_cache_key(provider: str, home: Path, api_key: str | None) -> tuple[str, str, str]:
|
||||
key_fingerprint = ""
|
||||
if api_key:
|
||||
@@ -1211,10 +1508,11 @@ def invalidate_account_usage_status_cache(provider_id: str | None = None) -> Non
|
||||
with _account_usage_status_cache_lock:
|
||||
if not normalized:
|
||||
_account_usage_status_cache.clear()
|
||||
return
|
||||
for key in list(_account_usage_status_cache):
|
||||
if key[0] == normalized:
|
||||
_account_usage_status_cache.pop(key, None)
|
||||
else:
|
||||
for key in list(_account_usage_status_cache):
|
||||
if key[0] == normalized:
|
||||
_account_usage_status_cache.pop(key, None)
|
||||
_close_account_usage_probe_workers_async()
|
||||
|
||||
|
||||
def _set_cached_account_usage(
|
||||
@@ -1246,51 +1544,11 @@ def _set_cached_account_usage(
|
||||
|
||||
def _agent_fetch_account_usage_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any:
|
||||
try:
|
||||
from api.config import PYTHON_EXE
|
||||
_cleanup_account_usage_probe_workers()
|
||||
return _get_account_usage_probe_worker(home).fetch(provider, api_key=api_key)
|
||||
except Exception:
|
||||
PYTHON_EXE = sys.executable or "python3"
|
||||
|
||||
try:
|
||||
# On POSIX (Linux/macOS), wire parent-death signal so the child dies
|
||||
# cleanly if the WebUI parent terminates. preexec_fn is not safe on
|
||||
# Windows, where OS-level process-tree cleanup handles child orphans.
|
||||
kwargs: dict[str, Any] = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.PIPE,
|
||||
"text": True,
|
||||
"timeout": _ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS,
|
||||
"check": False,
|
||||
}
|
||||
if hasattr(os, "fork"): # POSIX
|
||||
kwargs["preexec_fn"] = _account_usage_preexec_fn
|
||||
|
||||
proc = subprocess.run(
|
||||
[
|
||||
PYTHON_EXE, "-c",
|
||||
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
|
||||
provider,
|
||||
api_key or "",
|
||||
],
|
||||
env=_account_usage_subprocess_env(home, provider, api_key),
|
||||
**kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.debug("Account usage probe for %s timed out", provider)
|
||||
logger.debug("Account usage probe for %s failed", provider, exc_info=True)
|
||||
return None
|
||||
except Exception:
|
||||
logger.debug("Account usage probe for %s failed to launch", provider, exc_info=True)
|
||||
return None
|
||||
|
||||
if proc.returncode != 0:
|
||||
logger.debug("Account usage probe for %s exited with status %s", provider, proc.returncode)
|
||||
return None
|
||||
try:
|
||||
payload = json.loads((proc.stdout or "").strip() or "null")
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Account usage probe for %s returned invalid JSON", provider)
|
||||
return None
|
||||
return _account_usage_payload_to_snapshot(payload)
|
||||
|
||||
|
||||
def _fetch_account_usage_with_profile_context(provider: str, *, refresh: bool = False) -> Any:
|
||||
@@ -1301,8 +1559,7 @@ def _fetch_account_usage_with_profile_context(provider: str, *, refresh: bool =
|
||||
memory by spawning more than _MAX_CONCURRENT_ACCOUNT_USAGE_PROBES probe
|
||||
subprocesses simultaneously. Each probe runs up to 35 s.
|
||||
|
||||
A warm worker-pool (reuse of persistent subprocess handles) is a natural
|
||||
follow-up if this first slice proves insufficient in production.
|
||||
Warm per-profile worker processes handle the actual probe requests.
|
||||
"""
|
||||
home = _get_hermes_home()
|
||||
api_key = _get_provider_api_key(provider)
|
||||
@@ -2122,6 +2379,7 @@ def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
|
||||
# Using invalidate_models_cache() instead of reload_config() to avoid
|
||||
# disrupting active streaming sessions that may be reading config.cfg.
|
||||
invalidate_models_cache()
|
||||
invalidate_account_usage_status_cache(provider_id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
|
||||
@@ -3561,6 +3561,15 @@ _LOGIN_LOCALE = {
|
||||
"invalid_pw": "Ge\u00e7ersiz \u015fifre",
|
||||
"conn_failed": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z",
|
||||
},
|
||||
"pl": {
|
||||
"lang": "pl-PL",
|
||||
"title": "Zaloguj si\u0119",
|
||||
"subtitle": "Wpisz has\u0142o, aby kontynuowa\u0107",
|
||||
"placeholder": "Has\u0142o",
|
||||
"btn": "Zaloguj si\u0119",
|
||||
"invalid_pw": "Nieprawid\u0142owe has\u0142o",
|
||||
"conn_failed": "Po\u0142\u0105czenie nie powiod\u0142o si\u0119",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
1330
static/i18n.js
1330
static/i18n.js
File diff suppressed because it is too large
Load Diff
@@ -229,7 +229,7 @@ class TestOpenInVsCodeI18n:
|
||||
"""open_in_vscode key must appear exactly once per locale (11 total)."""
|
||||
src = I18N.read_text(encoding="utf-8")
|
||||
count = src.count("open_in_vscode:")
|
||||
assert count == 11, (
|
||||
assert count == 12, (
|
||||
f"Expected 11 open_in_vscode: entries (one per locale), found {count}"
|
||||
)
|
||||
|
||||
@@ -237,7 +237,7 @@ class TestOpenInVsCodeI18n:
|
||||
"""open_in_vscode_failed key must appear exactly once per locale (11 total)."""
|
||||
src = I18N.read_text(encoding="utf-8")
|
||||
count = src.count("open_in_vscode_failed:")
|
||||
assert count == 11, (
|
||||
assert count == 12, (
|
||||
f"Expected 11 open_in_vscode_failed: entries (one per locale), found {count}"
|
||||
)
|
||||
|
||||
|
||||
@@ -145,8 +145,15 @@ def test_audit_embedded_worker_import_line_anchors_are_source_lines():
|
||||
if finding["path"] == "api/providers.py" and finding["anchor"] == "agent.account_usage"
|
||||
)
|
||||
|
||||
assert account_usage["line"] == 168
|
||||
assert account_usage["text"] == "from agent.account_usage import fetch_account_usage"
|
||||
expected_text = "from agent.account_usage import fetch_account_usage"
|
||||
assert account_usage["text"] == expected_text
|
||||
# The reported line number must be an ACCURATE source anchor — i.e. that line
|
||||
# in the real file actually contains the import. Asserting the exact number is
|
||||
# brittle (any edit above it shifts it); asserting the line CONTENT at the
|
||||
# reported line is the real invariant ("line anchors are source lines").
|
||||
source_lines = (REPO / account_usage["path"]).read_text(encoding="utf-8").splitlines()
|
||||
assert account_usage["line"] >= 1
|
||||
assert source_lines[account_usage["line"] - 1].strip() == expected_text
|
||||
|
||||
|
||||
def test_audit_reports_runtime_state_and_provider_imports():
|
||||
|
||||
@@ -162,7 +162,7 @@ class TestAuxiliaryModelsI18n:
|
||||
"""Count of each key should equal the number of locales (12 with Turkish)."""
|
||||
for key in self.REQUIRED_KEYS:
|
||||
count = I18N_JS.count(f"{key}:")
|
||||
assert count == 12, (
|
||||
assert count == 13, (
|
||||
f"i18n key '{key}' found {count} times — expected 12 (one per locale)"
|
||||
)
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n:
|
||||
"voice_mode_toggle_active",
|
||||
)
|
||||
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl")
|
||||
|
||||
def test_legacy_voice_toggle_key_removed(self):
|
||||
"""The old key whose string was 'Voice input' caused the duplicate-
|
||||
@@ -171,7 +171,7 @@ class TestComposerVoiceButtonI18n:
|
||||
class TestVoiceModePreferenceGate:
|
||||
"""boot.js must hide btnVoiceMode by default, surface it via Preferences."""
|
||||
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl")
|
||||
|
||||
def test_voice_mode_pref_is_localstorage_backed(self):
|
||||
"""The pref reads from localStorage key 'hermes-voice-mode-button'."""
|
||||
|
||||
@@ -63,8 +63,8 @@ def test_context_indicator_surfaces_cache_hit_rate():
|
||||
def test_cache_usage_labels_are_localized():
|
||||
src = (ROOT / "static" / "i18n.js").read_text()
|
||||
|
||||
assert src.count("usage_cache_hit_detail:") == 12
|
||||
assert src.count("usage_cached_percent:") == 12
|
||||
assert src.count("usage_cache_hit_detail:") == 13
|
||||
assert src.count("usage_cached_percent:") == 13
|
||||
assert "usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)'" in src
|
||||
assert "usage_cached_percent: '{0}% cached'" in src
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ def test_panels_round_trip_and_hot_apply_hide_suggestions():
|
||||
|
||||
def test_hide_suggestions_i18n_all_locales_and_changelog():
|
||||
js = I18N.read_text(encoding="utf-8")
|
||||
assert js.count("settings_label_hide_suggestions:") == 12
|
||||
assert js.count("settings_desc_hide_suggestions:") == 12
|
||||
assert js.count("settings_label_hide_suggestions:") == 13
|
||||
assert js.count("settings_desc_hide_suggestions:") == 13
|
||||
changelog = CHANGELOG.read_text(encoding="utf-8")
|
||||
assert "#2679" in changelog
|
||||
assert "hide_empty_state_suggestions" in changelog
|
||||
|
||||
@@ -5,7 +5,7 @@ BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
LOCALE_COUNT = 12
|
||||
LOCALE_COUNT = 13
|
||||
|
||||
|
||||
def test_raw_audio_active_recording_uses_dedicated_i18n_key():
|
||||
|
||||
@@ -267,7 +267,7 @@ def test_login_locale_count_matches_or_exceeds_floor():
|
||||
assert k in login, f"_LOGIN_LOCALE missing core locale {k!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
|
||||
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
|
||||
def test_login_locale_entry_well_formed(loc_key: str):
|
||||
"""Each _LOGIN_LOCALE entry must have all required sub-keys and non-empty string values."""
|
||||
login = _load_login_locale()
|
||||
@@ -281,7 +281,7 @@ def test_login_locale_entry_well_formed(loc_key: str):
|
||||
|
||||
|
||||
def test_login_locale_resolver_handles_new_locales():
|
||||
"""_resolve_login_locale_key() must map ja/pt/ko (and common BCP-47 variants) to their entries."""
|
||||
"""_resolve_login_locale_key() must map ja/pt/ko/pl (and common BCP-47 variants) to their entries."""
|
||||
sys.path.insert(0, str(REPO))
|
||||
from api.routes import _resolve_login_locale_key
|
||||
|
||||
@@ -293,6 +293,9 @@ def test_login_locale_resolver_handles_new_locales():
|
||||
assert _resolve_login_locale_key("pt-PT") == "pt"
|
||||
assert _resolve_login_locale_key("ko") == "ko"
|
||||
assert _resolve_login_locale_key("ko-KR") == "ko"
|
||||
assert _resolve_login_locale_key("pl") == "pl"
|
||||
assert _resolve_login_locale_key("pl-PL") == "pl"
|
||||
assert _resolve_login_locale_key("pl_PL") == "pl"
|
||||
assert _resolve_login_locale_key("fr") == "fr"
|
||||
assert _resolve_login_locale_key("fr-FR") == "fr"
|
||||
assert _resolve_login_locale_key("fr-CA") == "fr"
|
||||
@@ -310,7 +313,7 @@ def _value_of(seg: str, key: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loc_key", ["es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
|
||||
@pytest.mark.parametrize("loc_key", ["es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
|
||||
def test_login_flow_keys_are_translated(loc_key: str):
|
||||
"""Login/sign-out/password keys in static/i18n.js must NOT equal the English value.
|
||||
|
||||
@@ -351,7 +354,7 @@ SESSION_MANAGEMENT_KEYS = (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
|
||||
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
|
||||
def test_session_management_keys_present(loc_key: str):
|
||||
"""Every locale block must define all session-management keys (no fallback to English)."""
|
||||
seg = _i18n_locale_block(loc_key)
|
||||
|
||||
201
tests/test_polish_locale.py
Normal file
201
tests/test_polish_locale.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def extract_locale_block(src: str, locale_key: str) -> str:
|
||||
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
|
||||
assert start_match, f"{locale_key} locale block not found"
|
||||
|
||||
start = start_match.end() - 1
|
||||
depth = 0
|
||||
in_single = False
|
||||
in_double = False
|
||||
in_backtick = False
|
||||
escape = False
|
||||
|
||||
for i in range(start, len(src)):
|
||||
ch = src[i]
|
||||
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
|
||||
if in_single:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == "'":
|
||||
in_single = False
|
||||
continue
|
||||
|
||||
if in_double:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_double = False
|
||||
continue
|
||||
|
||||
if in_backtick:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == "`":
|
||||
in_backtick = False
|
||||
continue
|
||||
|
||||
if ch == "'":
|
||||
in_single = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_double = True
|
||||
continue
|
||||
if ch == "`":
|
||||
in_backtick = True
|
||||
continue
|
||||
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start + 1 : i]
|
||||
|
||||
raise AssertionError(f"{locale_key} locale block braces are not balanced")
|
||||
|
||||
|
||||
def locale_keys(src: str, locale_key: str) -> list[str]:
|
||||
key_pattern = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*:", re.MULTILINE)
|
||||
return key_pattern.findall(extract_locale_block(src, locale_key))
|
||||
|
||||
|
||||
def test_polish_locale_block_exists():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
pl_block = extract_locale_block(src, "pl")
|
||||
assert pl_block
|
||||
assert "_lang: 'pl'" in pl_block
|
||||
assert "_label: 'Polski'" in pl_block
|
||||
assert "_speech: 'pl-PL'" in pl_block
|
||||
|
||||
|
||||
def test_polish_locale_includes_representative_translations():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
pl_block = extract_locale_block(src, "pl")
|
||||
expected = [
|
||||
"settings_title: 'Ustawienia'",
|
||||
"settings_label_language: 'Język'",
|
||||
"login_title: 'Zaloguj się'",
|
||||
"approval_heading: 'Wymagana aprobata'",
|
||||
"tab_chat: 'Czat'",
|
||||
"tab_tasks: 'Zadania'",
|
||||
"tab_profiles: 'Profile'",
|
||||
"empty_title: 'W czym mogę pomóc?'",
|
||||
"onboarding_title: 'Witaj w Hermes Web UI'",
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in pl_block
|
||||
|
||||
|
||||
def test_polish_settings_detail_descriptions_are_translated():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
pl_block = extract_locale_block(src, "pl")
|
||||
expected = [
|
||||
"settings_desc_workspace_panel_open: 'Gdy ta opcja jest włączona, panel obszaru roboczego / przeglądarki plików otwiera się automatycznie przy każdej nowej sesji. Nadal możesz go zamknąć ręcznie w dowolnym momencie.'",
|
||||
"settings_desc_notifications: 'Pokaż powiadomienie systemowe, gdy odpowiedź zostanie ukończona, podczas gdy aplikacja działa w tle.'",
|
||||
"settings_desc_token_usage: 'Wyświetla liczbę tokenów wejściowych/wyjściowych pod każdą odpowiedzią asystenta. Można też przełączyć za pomocą /usage.'",
|
||||
"settings_desc_sidebar_density: 'Kontroluje, ile metadanych wyświetla lista sesji na lewym pasku bocznym.'",
|
||||
"settings_desc_auto_title_refresh: 'Automatycznie generuje na nowo tytuł konwersacji na podstawie najnowszej wymiany, utrzymując go adekwatnym w miarę rozwoju rozmowy. Wymaga skonfigurowanego modelu LLM do generowania tytułów.'",
|
||||
"settings_desc_external_sessions: 'Pokaż konwersacje z CLI, Telegrama, Discorda, Slacka i innych kanałów na liście sesji. Kliknij, aby zaimportować i kontynuować.'",
|
||||
"settings_desc_cron_sessions: 'Wyświetlaj wyjście zadań cron jako konwersacje na pasku bocznym. Aktywne tylko wtedy, gdy włączone są sesje spoza WebUI. Domyślnie wyłączone; zadania o wysokiej częstotliwości mogą zalać pasek boczny.'",
|
||||
"settings_desc_sync_insights: 'Odzwierciedla zużycie tokenów WebUI w state.db, dzięki czemu hermes /insights uwzględnia dane sesji przeglądarki. Domyślnie wyłączone.'",
|
||||
"settings_desc_check_updates: 'Pokaż baner, gdy dostępne są nowsze wersje WebUI lub Agenta. Okresowo uruchamia pobieranie git fetch w tle.'",
|
||||
"settings_desc_bot_name: 'Używane tylko dla profilu domyślnego. Inne profile używają własnych nazw profilu.'",
|
||||
"settings_desc_password: 'Wpisz nowe hasło, aby je ustawić lub zmienić. Pozostaw puste, aby zachować obecne ustawienie.'",
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in pl_block
|
||||
|
||||
|
||||
def test_polish_locale_matches_english_key_coverage():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
en_keys = set(locale_keys(src, "en"))
|
||||
pl_keys = set(locale_keys(src, "pl"))
|
||||
assert sorted(en_keys - pl_keys) == []
|
||||
assert sorted(pl_keys - en_keys) == []
|
||||
|
||||
|
||||
def test_polish_locale_has_no_duplicate_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
keys = locale_keys(src, "pl")
|
||||
|
||||
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
|
||||
assert not duplicates, f"Polish locale has duplicate keys: {duplicates}"
|
||||
|
||||
|
||||
def test_polish_locale_keys_use_standard_indentation():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
pl_block = extract_locale_block(src, "pl")
|
||||
|
||||
# Enforce strict 4-space indentation for keys.
|
||||
badly_indented = []
|
||||
for line in pl_block.splitlines():
|
||||
m = re.match(r"^(\s*)[a-zA-Z0-9_]+\s*:", line)
|
||||
if m and len(m.group(1)) != 4:
|
||||
badly_indented.append(f"{len(m.group(1))} spaces: {line.strip()}")
|
||||
assert badly_indented == []
|
||||
|
||||
|
||||
def test_polish_locale_arrow_function_values_mirror_english():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
en_block = extract_locale_block(src, "en")
|
||||
pl_block = extract_locale_block(src, "pl")
|
||||
|
||||
value_re = re.compile(r"^\s+([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)", re.MULTILINE)
|
||||
arrow_re = re.compile(r"^\s*\(?[a-zA-Z_,\s]*\)?\s*=>")
|
||||
|
||||
def arrows(block):
|
||||
return {k for k, v in value_re.findall(block) if arrow_re.match(v)}
|
||||
|
||||
assert arrows(pl_block) == arrows(en_block)
|
||||
|
||||
|
||||
def test_polish_locale_preserves_placeholder_patterns():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
en_block = extract_locale_block(src, "en")
|
||||
pl_block = extract_locale_block(src, "pl")
|
||||
|
||||
value_re = re.compile(r"^\s+([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)", re.MULTILINE)
|
||||
placeholder_re = re.compile(r"\{[0-9]+\}|\$\{[a-zA-Z_][a-zA-Z0-9_]*\}")
|
||||
|
||||
def kv(block):
|
||||
out = {}
|
||||
for k, v in value_re.findall(block):
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
en_kv = kv(en_block)
|
||||
pl_kv = kv(pl_block)
|
||||
|
||||
for key, en_val in en_kv.items():
|
||||
if key not in pl_kv:
|
||||
continue
|
||||
en_vars = sorted(placeholder_re.findall(en_val))
|
||||
pl_vars = sorted(placeholder_re.findall(pl_kv[key]))
|
||||
# Skip arrow functions which might contain duplicate conditional logic and thus more template vars
|
||||
if "=>" not in pl_kv[key]:
|
||||
if en_vars or pl_vars:
|
||||
assert pl_vars == en_vars, f"Key '{key}' missing placeholders in pl locale"
|
||||
|
||||
|
||||
def test_polish_locale_has_no_double_escaped_unicode_sequences():
|
||||
"""JSON-style double escapes (\\\\u2026) render literal backslash-u in the UI."""
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
pl_block = extract_locale_block(src, "pl")
|
||||
for bad in ("\\\\u2026", "\\\\u2192", "\\\\u2713"):
|
||||
assert bad not in pl_block, f"Polish locale must not contain {bad!r}"
|
||||
@@ -112,5 +112,5 @@ def test_rtl_in_config_defaults_and_writable_keys():
|
||||
def test_rtl_localized_in_all_locales():
|
||||
js = I18N.read_text(encoding="utf-8")
|
||||
# Count occurrences — should match the 11 locale blocks
|
||||
assert js.count("settings_label_rtl:") == 12
|
||||
assert js.count("settings_desc_rtl:") == 12
|
||||
assert js.count("settings_label_rtl:") == 13
|
||||
assert js.count("settings_desc_rtl:") == 13
|
||||
|
||||
@@ -1307,37 +1307,252 @@ def test_provider_quota_styles_exist():
|
||||
|
||||
# ── Regression tests for #1912 ────────────────────────────────────────────────
|
||||
|
||||
def test_account_usage_subprocess_uses_devnull_stdin(monkeypatch):
|
||||
"""Account-usage probe subprocess must receive stdin=DEVNULL.
|
||||
|
||||
DEVNULL prevents the child from inheriting any pipe that could block or
|
||||
leak data. This is a defence-in-depth measure beyond the parent-death
|
||||
signal; it is tested separately to make the invariant explicit.
|
||||
"""
|
||||
class _FakeAccountUsageWorkerProcess:
|
||||
def __init__(self, payloads=None):
|
||||
self.payloads = list(payloads or [])
|
||||
self.requests = []
|
||||
self.returncode = None
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
self.stdin = self._Stdin(self)
|
||||
self.stdout = self._Stdout(self)
|
||||
|
||||
class _Stdin:
|
||||
def __init__(self, proc):
|
||||
self.proc = proc
|
||||
self.closed = False
|
||||
|
||||
def write(self, value):
|
||||
self.proc.requests.append(json.loads(value))
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class _Stdout:
|
||||
def __init__(self, proc):
|
||||
self.proc = proc
|
||||
self.closed = False
|
||||
|
||||
def readline(self):
|
||||
request = self.proc.requests[-1]
|
||||
if self.proc.payloads:
|
||||
payload = self.proc.payloads.pop(0)
|
||||
else:
|
||||
payload = {
|
||||
"provider": request["provider"],
|
||||
"source": "usage_api",
|
||||
"title": "Account limits",
|
||||
"windows": [],
|
||||
"details": [],
|
||||
"available": True,
|
||||
"unavailable_reason": None,
|
||||
"fetched_at": "2030-03-17T12:30:00Z",
|
||||
}
|
||||
return json.dumps(payload) + "\n"
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
self.returncode = -15
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return self.returncode
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
self.returncode = -9
|
||||
|
||||
|
||||
def test_account_usage_worker_reuses_process_for_same_home(monkeypatch, tmp_path):
|
||||
import api.providers as providers
|
||||
import subprocess
|
||||
|
||||
launched = []
|
||||
|
||||
def fake_popen(*args, **kwargs):
|
||||
proc = _FakeAccountUsageWorkerProcess()
|
||||
launched.append((args, kwargs, proc))
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
providers._close_account_usage_probe_workers()
|
||||
try:
|
||||
first = providers._agent_fetch_account_usage_for_home("openai-codex", tmp_path)
|
||||
second = providers._agent_fetch_account_usage_for_home("anthropic", tmp_path, api_key="sk-test")
|
||||
finally:
|
||||
providers._close_account_usage_probe_workers()
|
||||
|
||||
assert len(launched) == 1
|
||||
assert first.provider == "openai-codex"
|
||||
assert second.provider == "anthropic"
|
||||
assert launched[0][2].requests == [
|
||||
{"provider": "openai-codex", "api_key": "", "env_var": None},
|
||||
{"provider": "anthropic", "api_key": "sk-test", "env_var": "ANTHROPIC_API_KEY"},
|
||||
]
|
||||
|
||||
|
||||
def test_account_usage_worker_pool_is_keyed_by_home(monkeypatch, tmp_path):
|
||||
import api.providers as providers
|
||||
import subprocess
|
||||
|
||||
launched = []
|
||||
|
||||
def fake_popen(*args, **kwargs):
|
||||
proc = _FakeAccountUsageWorkerProcess()
|
||||
launched.append((args, kwargs, proc))
|
||||
return proc
|
||||
|
||||
home_a = tmp_path / "a"
|
||||
home_b = tmp_path / "b"
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
providers._close_account_usage_probe_workers()
|
||||
try:
|
||||
providers._agent_fetch_account_usage_for_home("openai-codex", home_a)
|
||||
providers._agent_fetch_account_usage_for_home("openai-codex", home_b)
|
||||
finally:
|
||||
providers._close_account_usage_probe_workers()
|
||||
|
||||
assert len(launched) == 2
|
||||
|
||||
|
||||
def test_account_usage_worker_idle_cleanup_closes_stale_process(monkeypatch, tmp_path):
|
||||
import api.providers as providers
|
||||
import subprocess
|
||||
|
||||
launched = []
|
||||
|
||||
def fake_popen(*args, **kwargs):
|
||||
proc = _FakeAccountUsageWorkerProcess()
|
||||
launched.append((args, kwargs, proc))
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
providers._close_account_usage_probe_workers()
|
||||
try:
|
||||
providers._agent_fetch_account_usage_for_home("openai-codex", tmp_path)
|
||||
worker = providers._account_usage_worker_pool[str(tmp_path)]
|
||||
providers._cleanup_account_usage_probe_workers(
|
||||
now=worker.last_used + providers._ACCOUNT_USAGE_WORKER_IDLE_SECONDS + 1
|
||||
)
|
||||
providers._agent_fetch_account_usage_for_home("openai-codex", tmp_path)
|
||||
finally:
|
||||
providers._close_account_usage_probe_workers()
|
||||
|
||||
assert len(launched) == 2
|
||||
assert launched[0][2].terminated is True
|
||||
|
||||
|
||||
def test_busy_account_usage_worker_uses_one_shot_fallback(monkeypatch, tmp_path):
|
||||
import api.providers as providers
|
||||
|
||||
worker = providers._AccountUsageProbeWorker(tmp_path)
|
||||
calls = []
|
||||
|
||||
def fake_one_shot(provider, home, *, api_key=None):
|
||||
calls.append((provider, Path(home), api_key))
|
||||
return SimpleNamespace(provider=provider, source="usage_api", windows=(), details=(), available=True)
|
||||
|
||||
monkeypatch.setattr(providers, "_fetch_account_usage_once_for_home", fake_one_shot)
|
||||
locked = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def hold_lock():
|
||||
with worker._lock:
|
||||
locked.set()
|
||||
release.wait(timeout=5)
|
||||
|
||||
holder = threading.Thread(target=hold_lock)
|
||||
holder.start()
|
||||
assert locked.wait(timeout=5)
|
||||
try:
|
||||
snapshot = worker.fetch("anthropic", api_key="sk-test")
|
||||
finally:
|
||||
release.set()
|
||||
holder.join(timeout=5)
|
||||
worker.close()
|
||||
|
||||
assert snapshot.provider == "anthropic"
|
||||
assert calls == [("anthropic", tmp_path, "sk-test")]
|
||||
|
||||
|
||||
def test_account_usage_cleanup_removes_null_proc_worker(monkeypatch, tmp_path):
|
||||
import api.providers as providers
|
||||
import subprocess
|
||||
|
||||
launched = []
|
||||
|
||||
def fake_popen(*args, **kwargs):
|
||||
proc = _FakeAccountUsageWorkerProcess()
|
||||
launched.append(proc)
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
providers._close_account_usage_probe_workers()
|
||||
try:
|
||||
providers._agent_fetch_account_usage_for_home("openai-codex", tmp_path)
|
||||
worker = providers._account_usage_worker_pool[str(tmp_path)]
|
||||
worker.close()
|
||||
providers._cleanup_account_usage_probe_workers()
|
||||
assert str(tmp_path) not in providers._account_usage_worker_pool
|
||||
finally:
|
||||
providers._close_account_usage_probe_workers()
|
||||
|
||||
assert len(launched) == 1
|
||||
|
||||
|
||||
def test_provider_key_mutation_invalidates_warm_account_usage_workers(monkeypatch, tmp_path):
|
||||
import api.providers as providers
|
||||
|
||||
invalidated = []
|
||||
|
||||
monkeypatch.setattr(providers, "_get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr(providers, "invalidate_models_cache", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
providers,
|
||||
"invalidate_account_usage_status_cache",
|
||||
lambda provider_id=None: invalidated.append(provider_id),
|
||||
)
|
||||
|
||||
updated = providers.set_provider_key("anthropic", "sk-test-quota-worker")
|
||||
removed = providers.set_provider_key("anthropic", None)
|
||||
|
||||
assert updated["ok"] is True
|
||||
assert removed["ok"] is True
|
||||
assert invalidated == ["anthropic", "anthropic"]
|
||||
|
||||
|
||||
def test_account_usage_worker_uses_controlled_pipe_stdin(monkeypatch):
|
||||
"""Account-usage probe workers must not inherit process stdin."""
|
||||
import api.providers as providers
|
||||
import subprocess
|
||||
|
||||
seen_stdin = None
|
||||
|
||||
def capturing_run(*args, **kwargs):
|
||||
def capturing_popen(*args, **kwargs):
|
||||
nonlocal seen_stdin
|
||||
seen_stdin = kwargs.get('stdin')
|
||||
class FakeProc:
|
||||
returncode = 0
|
||||
stdout = '{}'
|
||||
stderr = ''
|
||||
return FakeProc()
|
||||
return _FakeAccountUsageWorkerProcess()
|
||||
|
||||
monkeypatch.setattr(subprocess, 'run', capturing_run)
|
||||
monkeypatch.setattr(subprocess, 'Popen', capturing_popen)
|
||||
providers._close_account_usage_probe_workers()
|
||||
try:
|
||||
providers._agent_fetch_account_usage_for_home(
|
||||
'openai-codex', Path('/nonexistent'), api_key=None
|
||||
)
|
||||
except Exception:
|
||||
pass # errors are expected on a fake env; we only care about stdin
|
||||
finally:
|
||||
providers._close_account_usage_probe_workers()
|
||||
|
||||
assert seen_stdin is subprocess.DEVNULL, (
|
||||
f'expected stdin=subprocess.DEVNULL, got {seen_stdin!r}'
|
||||
assert seen_stdin is subprocess.PIPE, (
|
||||
f'expected stdin=subprocess.PIPE, got {seen_stdin!r}'
|
||||
)
|
||||
|
||||
|
||||
@@ -1387,24 +1602,21 @@ def test_account_usage_preexec_fn_is_wired_on_posix(monkeypatch):
|
||||
|
||||
captured_kwargs = {}
|
||||
|
||||
def capture_run(*args, **kwargs):
|
||||
def capture_popen(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
class FakeProc:
|
||||
returncode = 0
|
||||
stdout = '{}'
|
||||
stderr = ''
|
||||
return FakeProc()
|
||||
return _FakeAccountUsageWorkerProcess()
|
||||
|
||||
monkeypatch.setattr(subprocess, 'run', capture_run)
|
||||
monkeypatch.setattr(subprocess, 'Popen', capture_popen)
|
||||
providers._close_account_usage_probe_workers()
|
||||
try:
|
||||
providers._agent_fetch_account_usage_for_home(
|
||||
'openai-codex', Path('/nonexistent'), api_key=None
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
providers._close_account_usage_probe_workers()
|
||||
|
||||
assert 'preexec_fn' in captured_kwargs, (
|
||||
'preexec_fn should be in subprocess.run kwargs on POSIX'
|
||||
'preexec_fn should be in subprocess.Popen kwargs on POSIX'
|
||||
)
|
||||
assert captured_kwargs['preexec_fn'] is providers._account_usage_preexec_fn
|
||||
|
||||
@@ -1421,12 +1633,27 @@ def test_account_usage_semaphore_caps_concurrency(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(profiles, 'get_active_hermes_home', lambda: tmp_path)
|
||||
old_cfg, old_mtime = _with_config(model={'provider': 'openai-codex'})
|
||||
|
||||
barrier = threading.Barrier(2, timeout=2)
|
||||
active = 0
|
||||
max_active = 0
|
||||
entered = 0
|
||||
first_two_entered = threading.Event()
|
||||
third_entered = threading.Event()
|
||||
lock = threading.Lock()
|
||||
unblock = threading.Event()
|
||||
|
||||
def slow_fetch(provider, home, api_key=None):
|
||||
barrier.wait()
|
||||
nonlocal active, max_active, entered
|
||||
with lock:
|
||||
active += 1
|
||||
entered += 1
|
||||
max_active = max(max_active, active)
|
||||
if entered == providers._MAX_CONCURRENT_ACCOUNT_USAGE_PROBES:
|
||||
first_two_entered.set()
|
||||
if entered > providers._MAX_CONCURRENT_ACCOUNT_USAGE_PROBES:
|
||||
third_entered.set()
|
||||
unblock.wait(timeout=5)
|
||||
with lock:
|
||||
active -= 1
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(providers, '_agent_fetch_account_usage_for_home', slow_fetch)
|
||||
@@ -1442,16 +1669,22 @@ def test_account_usage_semaphore_caps_concurrency(monkeypatch, tmp_path):
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(2)]
|
||||
threads = [
|
||||
threading.Thread(target=worker)
|
||||
for _ in range(providers._MAX_CONCURRENT_ACCOUNT_USAGE_PROBES + 1)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
unblock.set()
|
||||
|
||||
try:
|
||||
assert first_two_entered.wait(timeout=2), 'first probe batch did not start'
|
||||
assert not third_entered.wait(timeout=0.2), 'third probe bypassed semaphore'
|
||||
unblock.set()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
assert not errors, f'workers raised: {errors}'
|
||||
assert len(results) == 2, f'expected 2 results, got {len(results)}'
|
||||
assert len(results) == len(threads), f'expected {len(threads)} results, got {len(results)}'
|
||||
assert max_active <= providers._MAX_CONCURRENT_ACCOUNT_USAGE_PROBES
|
||||
finally:
|
||||
unblock.set()
|
||||
_restore_config(old_cfg, old_mtime)
|
||||
|
||||
@@ -88,5 +88,5 @@ def test_quota_chip_panels_round_trip():
|
||||
|
||||
def test_quota_chip_localized_in_all_locales():
|
||||
js = I18N.read_text(encoding="utf-8")
|
||||
assert js.count("settings_label_quota_chip:") == 12, "12 locales expected"
|
||||
assert js.count("settings_desc_quota_chip:") == 12, "12 locales expected"
|
||||
assert js.count("settings_label_quota_chip:") == 13, "12 locales expected"
|
||||
assert js.count("settings_desc_quota_chip:") == 13, "12 locales expected"
|
||||
|
||||
@@ -779,8 +779,8 @@ def test_all_sessions_sidecar_refresh_stays_metadata_only(monkeypatch):
|
||||
assert rows[0]["last_message_at"] == 102.0
|
||||
|
||||
|
||||
def test_all_sessions_does_not_refresh_lineage_rows_from_sidecars(monkeypatch):
|
||||
"""Lineage rows are enriched from state.db; do not read every sidecar per poll."""
|
||||
def test_all_sessions_does_not_refresh_fresh_lineage_rows_from_sidecars(monkeypatch):
|
||||
"""Fresh lineage rows are enriched from state.db; do not read every sidecar per poll."""
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
@@ -789,8 +789,8 @@ def test_all_sessions_does_not_refresh_lineage_rows_from_sidecars(monkeypatch):
|
||||
"title": "Lineage Row",
|
||||
"message_count": 7,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 101.0,
|
||||
"last_message_at": 101.0,
|
||||
"updated_at": time.time() + 60.0,
|
||||
"last_message_at": time.time() + 60.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
"parent_session_id": "parent_sid",
|
||||
@@ -813,13 +813,117 @@ def test_all_sessions_does_not_refresh_lineage_rows_from_sidecars(monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
with patch.object(Session, "load_metadata_only", side_effect=AssertionError("lineage rows must not refresh sidecars")):
|
||||
with patch.object(Session, "load_metadata_only", side_effect=AssertionError("fresh lineage rows must not refresh sidecars")):
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert rows[0]["session_id"] == "lineage_sid"
|
||||
assert rows[0]["message_count"] == 7
|
||||
|
||||
|
||||
def test_all_sessions_refreshes_stale_visible_continuation_metadata(monkeypatch):
|
||||
"""A visible continuation whose sidecar advanced after _index.json must refresh metadata.
|
||||
|
||||
Compression lineage rows can remain the active sidebar representative while
|
||||
their sidecar gains the latest assistant turn. If the index row stays stale,
|
||||
the sidebar/topbar reports an old message count and the UI can look like the
|
||||
newest messages disappeared.
|
||||
"""
|
||||
session = Session(
|
||||
session_id="stale_visible_child",
|
||||
title="Long Conversation",
|
||||
messages=[
|
||||
{"role": "user", "content": "first", "timestamp": 100.0},
|
||||
{"role": "assistant", "content": "second", "timestamp": 101.0},
|
||||
{"role": "user", "content": "latest", "timestamp": 102.0},
|
||||
{"role": "assistant", "content": "latest answer", "timestamp": 103.0},
|
||||
],
|
||||
parent_session_id="snapshot_parent",
|
||||
updated_at=103.0,
|
||||
last_message_at=103.0,
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
{
|
||||
"session_id": "stale_visible_child",
|
||||
"title": "Long Conversation",
|
||||
"message_count": 2,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 100.0,
|
||||
"last_message_at": 100.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
"parent_session_id": "snapshot_parent",
|
||||
"_lineage_root_id": "snapshot_parent",
|
||||
"_compression_segment_count": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert rows[0]["session_id"] == "stale_visible_child"
|
||||
assert rows[0]["message_count"] == 4
|
||||
assert rows[0]["last_message_at"] == 103.0
|
||||
|
||||
|
||||
def test_all_sessions_does_not_refresh_plain_branch_fork_from_sidecar(monkeypatch):
|
||||
"""A plain /branch fork (session_source='fork') must NOT trigger a sidecar refresh.
|
||||
|
||||
Forks carry parent_session_id (#1342) but have no compression sidecar drift
|
||||
to correct. Including them in the continuation refresh gate would call
|
||||
load_metadata_only() on every fork row on every /api/sessions poll (the
|
||||
molasses #3770 guards against). The gate must exclude session_source='fork'
|
||||
so a fork's stale-looking index row is left alone.
|
||||
"""
|
||||
session = Session(
|
||||
session_id="plain_fork_child",
|
||||
title="Forked Conversation",
|
||||
messages=[
|
||||
{"role": "user", "content": "a", "timestamp": 100.0},
|
||||
{"role": "assistant", "content": "b", "timestamp": 101.0},
|
||||
{"role": "user", "content": "c", "timestamp": 102.0},
|
||||
{"role": "assistant", "content": "d", "timestamp": 103.0},
|
||||
],
|
||||
parent_session_id="some_parent",
|
||||
session_source="fork",
|
||||
updated_at=103.0,
|
||||
last_message_at=103.0,
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
{
|
||||
"session_id": "plain_fork_child",
|
||||
"title": "Forked Conversation",
|
||||
"message_count": 2,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 100.0,
|
||||
"last_message_at": 100.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
"parent_session_id": "some_parent",
|
||||
"session_source": "fork",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
# A fork that has not been hydrated must not be promoted from the (stale)
|
||||
# indexed count — the row stays as the index reports it (no sidecar refresh).
|
||||
def _fail_load(_sid):
|
||||
raise AssertionError("plain fork must not trigger load_metadata_only refresh")
|
||||
|
||||
monkeypatch.setattr(models.Session, "load_metadata_only", staticmethod(_fail_load))
|
||||
|
||||
rows = models.all_sessions()
|
||||
fork_row = next(r for r in rows if r["session_id"] == "plain_fork_child")
|
||||
assert fork_row["message_count"] == 2 # left at the indexed value, not refreshed
|
||||
|
||||
|
||||
def test_load_metadata_only_skips_index_read_when_sidecar_has_message_count(monkeypatch):
|
||||
"""Modern sidecars already carry message_count; avoid an _index.json read per row."""
|
||||
session = Session(
|
||||
|
||||
@@ -38,4 +38,4 @@ def test_verdigris_has_no_light_variant():
|
||||
def test_verdigris_i18n_lists_skin_in_all_locales():
|
||||
# There are 12 locales; each should now include verdigris as the trailing skin.
|
||||
# 10 locales use ASCII closing paren, 2 Chinese locales use full-width paren.
|
||||
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris)") == 12
|
||||
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris)") == 13
|
||||
|
||||
@@ -45,4 +45,4 @@ def test_zeus_modals_are_not_navy():
|
||||
|
||||
def test_zeus_i18n_lists_skin_in_all_locales():
|
||||
# There are 12 locales; each should include zeus in the skin list.
|
||||
assert I18N_JS.count("/zeus/") == 12
|
||||
assert I18N_JS.count("/zeus/") == 13
|
||||
|
||||
Reference in New Issue
Block a user