From 076607c9b982be48a86c460c2e3ce6492cd5a408 Mon Sep 17 00:00:00 2001 From: Afonso Coutinho Date: Wed, 3 Jun 2026 05:34:54 +0100 Subject: [PATCH] fix: archive browser model filter is suffix-only and drops matching models (#1709) --- routes/session_routes.py | 7 +- tests/test_archived_sessions_model_filter.py | 76 ++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/test_archived_sessions_model_filter.py diff --git a/routes/session_routes.py b/routes/session_routes.py index 9b84334..d3b926f 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -618,7 +618,12 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ safe_search = search.replace('%', r'\%').replace('_', r'\_') q = q.filter(DbSession.name.ilike(f"%{safe_search}%", escape='\\')) if model: - q = q.filter(DbSession.model.ilike(f"%{model}")) + # Contains match (mirrors the name filter above). The old + # f"%{model}" was a SUFFIX-only match, so filtering by "gpt-4" + # dropped "gpt-4o" and over-matched on shared suffixes; it also + # left LIKE wildcards in the user value unescaped. + safe_model = model.replace('%', r'\%').replace('_', r'\_') + q = q.filter(DbSession.model.ilike(f"%{safe_model}%", escape='\\')) total = q.count() sort_map = { "recent": DbSession.updated_at.desc(), diff --git a/tests/test_archived_sessions_model_filter.py b/tests/test_archived_sessions_model_filter.py new file mode 100644 index 0000000..32c8420 --- /dev/null +++ b/tests/test_archived_sessions_model_filter.py @@ -0,0 +1,76 @@ +"""Archive browser model filter must be a CONTAINS match, not suffix-only. + +list_archived_sessions filtered with DbSession.model.ilike(f"%{model}") - a +suffix match. Filtering by "gpt-4" therefore returned "openai/gpt-4" but +silently DROPPED "gpt-4o" (contains but does not end with the value), and +over-matched models that merely share the suffix. The sibling name filter +already uses a wildcard-escaped contains match. +""" +import tempfile +import uuid + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb +from core.database import Session as DbSession + +_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_ENGINE = create_engine( + f"sqlite:///{_TMPDB.name}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, +) +cdb.Base.metadata.create_all(_ENGINE) +_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False) + + +def _route(router, path, method="GET"): + for r in router.routes: + if r.path == path and method in getattr(r, "methods", set()): + return r.endpoint + raise AssertionError(f"route not found: {path}") + + +@pytest.fixture +def archived_endpoint(monkeypatch): + import routes.session_routes as sr + from unittest.mock import MagicMock + + monkeypatch.setattr(sr, "SessionLocal", _TS) + monkeypatch.setattr(sr, "effective_user", lambda request: "alice") + router = sr.setup_session_routes(MagicMock(), {}) + return _route(router, "/api/sessions/archived") + + +def _seed(owner, *models): + db = _TS() + try: + db.query(DbSession).delete() + for m in models: + db.add(DbSession(id=str(uuid.uuid4()), owner=owner, name=f"chat {m}", + model=m, archived=True)) + db.commit() + finally: + db.close() + + +def test_contains_match_returns_all_models_sharing_the_substring(archived_endpoint): + _seed("alice", "openai/gpt-4", "gpt-4o", "claude-3") + res = archived_endpoint(request=None, model="gpt-4") + got = {s["model"] for s in res["sessions"]} + assert got == {"openai/gpt-4", "gpt-4o"} + + +def test_exact_full_model_still_matches(archived_endpoint): + _seed("alice", "openai/gpt-4", "gpt-4o") + res = archived_endpoint(request=None, model="openai/gpt-4") + assert {s["model"] for s in res["sessions"]} == {"openai/gpt-4"} + + +def test_wildcard_in_filter_is_escaped(archived_endpoint): + _seed("alice", "gpt-4o", "gpt_4o") + res = archived_endpoint(request=None, model="gpt_4") + assert {s["model"] for s in res["sessions"]} == {"gpt_4o"}