ROOT FIX (product bug): POST /api/settings never invalidated the session-list cache, so toggling show_cli_sessions/show_cron_sessions could serve a stale sidebar for up to the cache TTL when the settings-file mtime stamp collided with a cached entry. The handler now clears the cache directly on those toggles. This is also the root cause of the recurring test_gateway_sync CI flake (session absent from /api/sessions right after the visibility toggle). DEFENSE-IN-DEPTH: cleanup_test_sessions fixture now resets show_cli_sessions to default BEFORE each test too, not only in teardown. FLAKE FIX (test infra): bump 31 node-subprocess correctness-harness timeouts from 10s -> 30s across 23 test files. Node spawn + V8 warmup under parallel CI shard load can exceed 10s, causing TimeoutExpired flakes (e.g. test_1325 on #4445). Only real node subprocess.run() sites changed — mock signatures, HTTP urlopen, and deliberate timeout-behavior tests (test_api_timeout, timeout=0/1/2) untouched.
156 lines
5.5 KiB
Python
156 lines
5.5 KiB
Python
"""Regression tests for #3959 — Model selector shows duplicate entries for
|
|
colon-suffixed model IDs (e.g. :free, :thinking, :discounted).
|
|
|
|
The normalizer was using parts[-1] (last colon segment) which collapsed all
|
|
:free models to the same key 'free'. Fix: strip only the @provider: prefix
|
|
(first colon after @), preserving the rest including colon-suffixed suffixes.
|
|
"""
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent
|
|
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
|
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _exec_nested_fn(start_marker: str, end_marker: str, fn_name: str):
|
|
s = CONFIG_PY.find(start_marker)
|
|
e = CONFIG_PY.find(end_marker, s)
|
|
assert s != -1 and e != -1
|
|
body = CONFIG_PY[s:e]
|
|
lines = body.splitlines()
|
|
indent = None
|
|
for ln in lines:
|
|
if ln.strip():
|
|
indent = len(ln) - len(ln.lstrip())
|
|
break
|
|
dedented = "\n".join(ln[indent:] if len(ln) >= indent else ln for ln in lines)
|
|
ns = {}
|
|
exec(dedented, ns)
|
|
return ns[fn_name]
|
|
|
|
|
|
def _exec_norm():
|
|
"""Re-execute the _norm_model_id closure body via a synthetic def."""
|
|
return _exec_nested_fn(
|
|
"def _norm_model_id(model_id: str) -> str:",
|
|
"def _build_configured_model_badges",
|
|
"_norm_model_id",
|
|
)
|
|
|
|
|
|
def _exec_static_norm():
|
|
"""Re-execute the _norm_static_model_id helper via a synthetic def."""
|
|
return _exec_nested_fn(
|
|
"def _norm_static_model_id(model_id: str) -> str:",
|
|
"norm_lookup: dict[str, list[str]] = {}",
|
|
"_norm_static_model_id",
|
|
)
|
|
|
|
|
|
def test_colon_suffix_model_preserves_suffix():
|
|
"""@custom:llm-proxy:kilo/nvidia/nemotron-3-ultra-550b-a55b:free must
|
|
NOT collapse to just 'free'."""
|
|
norm = _exec_norm()
|
|
result = norm("@custom:llm-proxy:kilo/nvidia/nemotron-3-ultra-550b-a55b:free")
|
|
assert "free" in result, f"Expected 'free' in result, got {result!r}"
|
|
assert result != "free", f"Suffix-only collapse is the bug: {result!r}"
|
|
|
|
|
|
def test_free_vs_thinking_produce_different_keys():
|
|
""":free and :thinking variants of the same model must normalize to
|
|
different keys to avoid duplicate selector entries."""
|
|
norm = _exec_norm()
|
|
base = "@custom:llm-proxy:kilo/nvidia/nemotron-3-ultra-550b-a55b"
|
|
key_free = norm(f"{base}:free")
|
|
key_thinking = norm(f"{base}:thinking")
|
|
assert key_free != key_thinking, (
|
|
f":free and :thinking collapsed to same key: "
|
|
f"free={key_free!r}, thinking={key_thinking!r}"
|
|
)
|
|
|
|
|
|
def test_plain_model_id_still_normalizes():
|
|
"""Simple model IDs without @ prefix must still normalize correctly."""
|
|
norm = _exec_norm()
|
|
assert norm("gpt-4") == "gpt.4"
|
|
assert norm("") == ""
|
|
assert norm(None) == ""
|
|
|
|
|
|
def test_provider_prefix_only_model():
|
|
"""@custom:vendor:model (no colon suffix) still strips prefix."""
|
|
norm = _exec_norm()
|
|
result = norm("@custom:jingdong:GLM-5")
|
|
assert result == "jingdong:glm.5"
|
|
|
|
|
|
def test_ui_js_uses_indexof_not_split_pop():
|
|
"""Frontend must use indexOf(':',1)+slice, not split(':').pop()."""
|
|
assert "indexOf(':',1)" in UI_JS
|
|
assert "s.split(':').pop()" not in UI_JS, (
|
|
"ui.js still uses the buggy split(':').pop() pattern"
|
|
)
|
|
assert "strippedAtProvider=!!cand" in UI_JS
|
|
|
|
|
|
def test_colon_before_slash_prefix_matches_backend_paths():
|
|
"""The non-@ colon-before-slash strip must match both backend helpers."""
|
|
norm = _exec_norm()
|
|
static_norm = _exec_static_norm()
|
|
model_id = "custom:llm-proxy/opencode_go/deepseek-v4-pro"
|
|
assert norm(model_id) == "deepseek.v4.pro"
|
|
assert static_norm(model_id) == "deepseek.v4.pro"
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not in PATH")
|
|
def test_backend_frontend_parity_complex_aggregator_proxy():
|
|
"""Python and JS normalizers must stay byte-for-byte aligned."""
|
|
py_norm = _exec_norm()
|
|
static_norm = _exec_static_norm()
|
|
import re
|
|
js_match = re.search(
|
|
r"function _normalizeConfiguredModelKey\([^)]*\)\{(.+?)\n\}",
|
|
UI_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert js_match, "Could not find _normalizeConfiguredModelKey in ui.js"
|
|
js_body = js_match.group(1)
|
|
js_fn = f"""
|
|
function _normalizeConfiguredModelKey(modelId){{{js_body}}}
|
|
"""
|
|
import subprocess, json, tempfile, os
|
|
test_ids = [
|
|
"@custom:jingdong:GLM-5",
|
|
"@custom:llm-proxy:kilo/nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
"@custom:proxy:nvidia/model:free",
|
|
"@custom:host:8080:model",
|
|
"openrouter/deepseek:free",
|
|
"custom:llm-proxy/opencode_go/deepseek-v4-pro",
|
|
]
|
|
js_code = js_fn + "\n" + f"console.log(JSON.stringify({json.dumps(test_ids)}.map(id => [id, _normalizeConfiguredModelKey(id)])))"
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".js", delete=False) as f:
|
|
f.write(js_code)
|
|
tmp = f.name
|
|
try:
|
|
result = subprocess.run(
|
|
["node", tmp], capture_output=True, text=True, timeout=30
|
|
)
|
|
assert result.returncode == 0, f"JS execution failed: {result.stderr}"
|
|
js_pairs = json.loads(result.stdout.strip())
|
|
js_map = dict(js_pairs)
|
|
finally:
|
|
os.unlink(tmp)
|
|
for model_id in test_ids:
|
|
py_result = py_norm(model_id)
|
|
static_result = static_norm(model_id)
|
|
js_result = js_map[model_id]
|
|
assert py_result == static_result == js_result, (
|
|
f"Parity mismatch for {model_id!r}: "
|
|
f"Python={py_result!r}, static={static_result!r}, JS={js_result!r}"
|
|
)
|