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>
439 lines
15 KiB
Python
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
|