Files
hermes-webui/tests/test_issue2929_settings_max_tokens.py
T
nesquena-hermesandrodboev 20d0313d0c feat(settings): expose max_tokens override in Settings (#5010)
The streaming path already honored a max_tokens cap from the active profile's
config.yaml, but there was no UI to manage it. Settings > Preferences now has a
"Max output tokens" field that writes a root-level max_tokens override through
the profile config (not settings.json, which the streaming worker never reads
for this), preserving unrelated YAML keys under _cfg_lock. Blank clears the
override so agent.max_tokens fallback resumes; get_max_tokens_status() surfaces
root / effective / fallback so the field shows what a new turn would use.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-27 20:27:18 +00:00

439 lines
15 KiB
Python

"""Regression coverage for the Settings-side max_tokens bridge."""
import io
import json
import re
from pathlib import Path
from urllib.parse import urlparse
import pytest
def _function_block(src: str, name: str) -> str:
marker = re.search(rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src)
assert marker is not None, f"{name}() not found"
start = marker.start()
next_marker = re.search(r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1:])
end = start + 1 + next_marker.start() if next_marker else len(src)
return src[start:end]
@pytest.fixture(autouse=True)
def _isolate_config_files(tmp_path, monkeypatch):
import api.config as config
settings_path = tmp_path / "settings.json"
config_path = tmp_path / "config.yaml"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
monkeypatch.setattr(config, "reload_config", lambda: None)
yield
class _FakeHandler:
def __init__(self, body_bytes: bytes = b""):
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
self.rfile = io.BytesIO(body_bytes)
self.headers = {"Content-Length": str(len(body_bytes))}
self.request = None
self.client_address = ("127.0.0.1", 0)
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def json_body(self):
return json.loads(bytes(self.body).decode("utf-8"))
def _write_config(text: str) -> Path:
import api.config as config
config_path = config._get_config_path()
config_path.write_text(text, encoding="utf-8")
return config_path
def _read_config():
import api.config as config
return config._load_yaml_config_file(config._get_config_path())
def test_get_max_tokens_status_prefers_root_then_agent():
import api.config as config
_write_config(
"max_tokens: 256\n"
"agent:\n"
" max_tokens: 512\n"
" name: fallback\n"
)
assert config.get_max_tokens_status() == {
"max_tokens": 256,
"max_tokens_effective": 256,
"max_tokens_fallback": None,
}
_write_config(
"max_tokens: -1\n"
"agent:\n"
" max_tokens: 512\n"
" name: fallback\n"
)
assert config.get_max_tokens_status() == {
"max_tokens": None,
"max_tokens_effective": None,
"max_tokens_fallback": None,
}
_write_config(
"agent:\n"
" max_tokens: 512\n"
" name: fallback\n"
)
assert config.get_max_tokens_status() == {
"max_tokens": None,
"max_tokens_effective": 512,
"max_tokens_fallback": 512,
}
_write_config(
"max_tokens: null\n"
"agent:\n"
" max_tokens: 512\n"
" name: fallback\n"
)
assert config.get_max_tokens_status() == {
"max_tokens": None,
"max_tokens_effective": 512,
"max_tokens_fallback": 512,
}
_write_config("max_tokens: true\n")
assert config.get_max_tokens_status() == {
"max_tokens": 1,
"max_tokens_effective": 1,
"max_tokens_fallback": None,
}
def test_set_max_tokens_writes_root_override_and_clears_back_to_agent_fallback(monkeypatch):
import api.config as config
monkeypatch.setenv("OPENAI_API_KEY", "expanded-secret")
_write_config(
"agent:\n"
" max_tokens: 512\n"
" name: fallback\n"
"providers:\n"
" openai:\n"
" api_key: ${OPENAI_API_KEY}\n"
"metadata:\n"
" keep: true\n"
)
saved = config.set_max_tokens(768)
assert saved == {
"max_tokens": 768,
"max_tokens_effective": 768,
"max_tokens_fallback": None,
}
data = _read_config()
assert data["max_tokens"] == 768
assert data["agent"]["max_tokens"] == 512
assert data["agent"]["name"] == "fallback"
assert config._load_yaml_config_file_raw(config._get_config_path())["providers"]["openai"]["api_key"] == "${OPENAI_API_KEY}"
assert data["metadata"]["keep"] is True
saved = config.set_max_tokens(None)
assert saved == {
"max_tokens": None,
"max_tokens_effective": 512,
"max_tokens_fallback": 512,
}
data = _read_config()
assert "max_tokens" not in data
assert data["agent"]["max_tokens"] == 512
assert config._load_yaml_config_file_raw(config._get_config_path())["providers"]["openai"]["api_key"] == "${OPENAI_API_KEY}"
assert data["metadata"]["keep"] is True
def test_set_max_tokens_invalid_non_empty_input_is_a_true_no_op(monkeypatch):
import api.config as config
_write_config(
"agent:\n"
" max_tokens: 512\n"
" name: fallback\n"
)
monkeypatch.setattr(
config,
"_save_yaml_config_file",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unexpected config save")),
)
monkeypatch.setattr(
config,
"reload_config",
lambda: (_ for _ in ()).throw(AssertionError("unexpected reload")),
)
assert config.set_max_tokens("abc") == {
"max_tokens": None,
"max_tokens_effective": 512,
"max_tokens_fallback": 512,
}
assert _read_config()["agent"]["max_tokens"] == 512
def test_set_max_tokens_blank_without_root_override_is_a_true_no_op(monkeypatch):
import api.config as config
_write_config(
"agent:\n"
" max_tokens: 512\n"
" name: fallback\n"
)
monkeypatch.setattr(
config,
"_save_yaml_config_file",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unexpected config save")),
)
monkeypatch.setattr(
config,
"reload_config",
lambda: (_ for _ in ()).throw(AssertionError("unexpected reload")),
)
assert config.set_max_tokens(None) == {
"max_tokens": None,
"max_tokens_effective": 512,
"max_tokens_fallback": 512,
}
assert _read_config()["agent"]["max_tokens"] == 512
def test_get_settings_exposes_max_tokens_from_the_active_profile(monkeypatch):
import api.config as config
from api.routes import handle_get
monkeypatch.setattr(
"api.routes.load_settings",
lambda: {"send_key": "enter", "password_hash": "secret"},
)
monkeypatch.setattr(
config,
"get_max_tokens_status",
lambda: {
"max_tokens": 321,
"max_tokens_effective": 321,
"max_tokens_fallback": None,
},
)
handler = _FakeHandler()
handle_get(handler, urlparse("http://example.com/api/settings"))
payload = handler.json_body()
assert handler.status == 200
assert payload["send_key"] == "enter"
assert payload["max_tokens"] == 321
assert payload["max_tokens_effective"] == 321
assert payload["max_tokens_fallback"] is None
assert "password_hash" not in payload
def test_post_settings_bridges_max_tokens_without_polluting_settings_payload(monkeypatch):
import api.auth as auth
from api.routes import handle_post
captured = {}
monkeypatch.setattr(auth, "is_auth_enabled", lambda: False)
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
monkeypatch.setattr(auth, "parse_cookie", lambda handler: "")
monkeypatch.setattr(auth, "verify_session", lambda cookie: False)
def _fake_save_settings(body):
captured["body"] = dict(body)
return {"send_key": body.get("send_key")}
monkeypatch.setattr("api.routes.save_settings", _fake_save_settings)
monkeypatch.setattr(
"api.config.set_max_tokens",
lambda value: {
"max_tokens": 777 if value == 123 else None,
"max_tokens_effective": 777 if value == 123 else None,
"max_tokens_fallback": None,
},
)
handler = _FakeHandler(json.dumps({"send_key": "enter", "max_tokens": 123}).encode("utf-8"))
handle_post(handler, urlparse("http://example.com/api/settings"))
payload = handler.json_body()
assert handler.status == 200
assert captured["body"] == {"send_key": "enter"}
assert payload["send_key"] == "enter"
assert payload["max_tokens"] == 777
assert payload["max_tokens_effective"] == 777
assert payload["max_tokens_fallback"] is None
def test_post_settings_keeps_current_max_tokens_on_unrelated_save(monkeypatch):
import api.auth as auth
from api.routes import handle_post
monkeypatch.setattr(auth, "is_auth_enabled", lambda: False)
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
monkeypatch.setattr(auth, "parse_cookie", lambda handler: "")
monkeypatch.setattr(auth, "verify_session", lambda cookie: False)
monkeypatch.setattr("api.routes.save_settings", lambda body: {"language": body.get("language")})
monkeypatch.setattr(
"api.config.get_max_tokens_status",
lambda: {
"max_tokens": 100,
"max_tokens_effective": 100,
"max_tokens_fallback": None,
},
)
monkeypatch.setattr(
"api.config.set_max_tokens",
lambda value: (_ for _ in ()).throw(AssertionError(f"unexpected max_tokens write: {value!r}")),
)
handler = _FakeHandler(json.dumps({"language": "pl"}).encode("utf-8"))
handle_post(handler, urlparse("http://example.com/api/settings"))
payload = handler.json_body()
assert handler.status == 200
assert payload["language"] == "pl"
assert payload["max_tokens"] == 100
assert payload["max_tokens_effective"] == 100
assert payload["max_tokens_fallback"] is None
def test_post_settings_does_not_write_max_tokens_before_auth_failures(monkeypatch):
import api.auth as auth
from api.routes import handle_post
saw_set_max_tokens = {"called": False}
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
monkeypatch.setattr(auth, "get_password_hash", lambda: "hash")
monkeypatch.setattr(auth, "parse_cookie", lambda handler: "")
monkeypatch.setattr(auth, "verify_session", lambda cookie: False)
monkeypatch.setattr(auth, "verify_password", lambda current_password: False)
monkeypatch.setattr(
"api.config.set_max_tokens",
lambda value: saw_set_max_tokens.__setitem__("called", True),
)
monkeypatch.setattr(
"api.routes.save_settings",
lambda body: (_ for _ in ()).throw(AssertionError("save_settings should not run")),
)
handler = _FakeHandler(
json.dumps(
{
"send_key": "enter",
"max_tokens": 123,
"_clear_password": True,
"_current_password": "wrong",
}
).encode("utf-8")
)
handle_post(handler, urlparse("http://example.com/api/settings"))
payload = handler.json_body()
assert handler.status == 403
assert payload["error"] == "Current password is incorrect."
assert saw_set_max_tokens["called"] is False
def test_post_settings_does_not_write_max_tokens_when_save_settings_fails(monkeypatch):
import api.auth as auth
from api.routes import handle_post
saw_set_max_tokens = {"called": False}
monkeypatch.setattr(auth, "is_auth_enabled", lambda: False)
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
monkeypatch.setattr(auth, "parse_cookie", lambda handler: "")
monkeypatch.setattr(auth, "verify_session", lambda cookie: False)
monkeypatch.setattr(
"api.routes.save_settings",
lambda body: (_ for _ in ()).throw(RuntimeError("save_settings failed")),
)
monkeypatch.setattr(
"api.config.set_max_tokens",
lambda value: saw_set_max_tokens.__setitem__("called", True),
)
handler = _FakeHandler(json.dumps({"send_key": "enter", "max_tokens": 123}).encode("utf-8"))
with pytest.raises(RuntimeError, match="save_settings failed"):
handle_post(handler, urlparse("http://example.com/api/settings"))
assert saw_set_max_tokens["called"] is False
def test_settings_panel_wires_max_tokens_for_dirty_state_and_manual_save():
import pathlib
panels_js = (pathlib.Path(__file__).parent.parent / "static" / "panels.js").read_text(encoding="utf-8")
index_html = (pathlib.Path(__file__).parent.parent / "static" / "index.html").read_text(encoding="utf-8")
i18n_js = (pathlib.Path(__file__).parent.parent / "static" / "i18n.js").read_text(encoding="utf-8")
load_block = _function_block(panels_js, "loadSettingsPanel")
load_idx = load_block.find("$('settingsMaxTokens')")
assert load_idx != -1
load_window = load_block[load_idx:load_idx + 420]
assert "settings.max_tokens" in load_window
assert "_syncSettingsMaxTokensPlaceholder(maxTokensField,settings.max_tokens_fallback)" in load_block.replace(" ", "")
assert "maxTokensField.dataset.initialValue=maxTokensField.value" in load_block.replace(" ", "")
assert "maxTokensField.addEventListener('input',_markSettingsDirty" in load_block.replace(" ", "")
assert "_schedulePreferencesAutosave" not in load_window
apply_saved_block = _function_block(panels_js, "_applySavedSettingsUi")
assert "saved&&saved.max_tokens" in apply_saved_block
assert "_syncSettingsMaxTokensPlaceholder(maxTokensField,saved&&saved.max_tokens_fallback)" in apply_saved_block.replace(" ", "")
assert "maxTokensField.dataset.initialValue=maxTokensField.value" in apply_saved_block.replace(" ", "")
autosave_block = _function_block(panels_js, "_autosavePreferencesSettings")
assert "const maxTokensField=$('settingsMaxTokens');" in autosave_block
assert "String(maxTokensField.value||'')!==String(maxTokensField.dataset.initialValue||'')" in autosave_block.replace(" ", "")
compact_autosave = autosave_block.replace(" ", "")
assert "if(!pwDirty&&!modelDirty)" in compact_autosave
assert "if(!maxTokensDirty)" in compact_autosave
prefs_block = _function_block(panels_js, "_preferencesPayloadFromUi")
assert "settingsMaxTokens" not in prefs_block
assert "max_tokens" not in prefs_block
save_block = _function_block(panels_js, "saveSettings")
assert "settingsMaxTokens" in save_block
assert "body.max_tokens" in save_block
assert "initialMaxTokens" in save_block
assert "constinitialMaxTokens=String(maxTokensField.dataset.initialValue||'').trim();" in save_block.replace(" ", "")
assert "if(maxTokensRaw!==initialMaxTokens)" in save_block.replace(" ", "")
assert "body.max_tokens=maxTokensRaw===''?null:maxTokensRaw" in save_block.replace(" ", "")
assert 'id="settingsMaxTokens"' in index_html
assert 'data-i18n-placeholder="settings_placeholder_max_tokens_none"' in index_html
assert "settings_label_max_tokens" in i18n_js
assert "settings_desc_max_tokens" in i18n_js
assert "settings_placeholder_max_tokens_none" in i18n_js
assert "settings_placeholder_max_tokens_fallback" in i18n_js