diff --git a/.gitignore b/.gitignore
index d7801d0b..cde41326 100644
--- a/.gitignore
+++ b/.gitignore
@@ -182,6 +182,7 @@ gpt4all-chat/models/*
 build_*
 build-*
 cmake-build-*
+/gpt4all-chat/tests/python/config.py
 
 # IntelliJ
 .idea/
diff --git a/gpt4all-chat/.flake8 b/gpt4all-chat/.flake8
new file mode 100644
index 00000000..535d840a
--- /dev/null
+++ b/gpt4all-chat/.flake8
@@ -0,0 +1,5 @@
+# vim: set syntax=dosini:
+[flake8]
+exclude = .*,__pycache__
+max-line-length = 120
+extend-ignore = B001,C408,D,DAR,E221,E303,E722,E741,E800,N801,N806,P101,S101,S324,S404,S406,S410,S603,WPS100,WPS110,WPS111,WPS113,WPS114,WPS115,WPS120,WPS2,WPS300,WPS301,WPS304,WPS305,WPS306,WPS309,WPS316,WPS317,WPS318,WPS319,WPS322,WPS323,WPS326,WPS329,WPS330,WPS332,WPS336,WPS337,WPS347,WPS360,WPS361,WPS414,WPS420,WPS421,WPS429,WPS430,WPS431,WPS432,WPS433,WPS437,WPS440,WPS440,WPS441,WPS442,WPS457,WPS458,WPS460,WPS462,WPS463,WPS473,WPS501,WPS504,WPS505,WPS508,WPS509,WPS510,WPS515,WPS516,WPS519,WPS529,WPS531,WPS602,WPS604,WPS605,WPS608,WPS609,WPS613,WPS615
diff --git a/gpt4all-chat/CMakeLists.txt b/gpt4all-chat/CMakeLists.txt
index 3f1a7d26..0950774a 100644
--- a/gpt4all-chat/CMakeLists.txt
+++ b/gpt4all-chat/CMakeLists.txt
@@ -22,7 +22,7 @@ if(APPLE)
   endif()
 endif()
 
-find_package(Python3 QUIET COMPONENTS Interpreter)
+find_package(Python3 3.12 QUIET COMPONENTS Interpreter)
 
 option(GPT4ALL_TEST "Build the tests" ${Python3_FOUND})
 option(GPT4ALL_LOCALHOST "Build installer for localhost repo" OFF)
@@ -101,7 +101,7 @@ if (GPT4ALL_TEST)
     add_subdirectory(tests)
 
     # The 'check' target makes sure the tests and their dependencies are up-to-date before running them
-    add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} DEPENDS chat gpt4all_tests)
+    add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure DEPENDS chat gpt4all_tests)
 endif()
 
 set(CHAT_EXE_RESOURCES)
diff --git a/gpt4all-chat/dev-requirements.txt b/gpt4all-chat/dev-requirements.txt
new file mode 100644
index 00000000..a29ac981
--- /dev/null
+++ b/gpt4all-chat/dev-requirements.txt
@@ -0,0 +1,11 @@
+-r test-requirements.txt
+
+# dev tools
+flake8~=7.1
+mypy~=1.12
+pytype>=2024.10.11
+wemake-python-styleguide~=0.19.2
+
+# type stubs and other optional modules
+types-requests~=2.32
+urllib3[socks]
diff --git a/gpt4all-chat/pyproject.toml b/gpt4all-chat/pyproject.toml
new file mode 100644
index 00000000..12b9fc43
--- /dev/null
+++ b/gpt4all-chat/pyproject.toml
@@ -0,0 +1,29 @@
+[tool.pytest.ini_options]
+addopts = ['--import-mode=importlib']
+
+[tool.mypy]
+files = 'tests/python'
+pretty = true
+strict = true
+warn_unused_ignores = false
+
+[tool.pytype]
+inputs = ['tests/python']
+jobs = 'auto'
+bind_decorated_methods = true
+none_is_not_bool = true
+overriding_renamed_parameter_count_checks = true
+strict_none_binding = true
+precise_return = true
+# protocols:
+# - https://github.com/google/pytype/issues/1423
+# - https://github.com/google/pytype/issues/1424
+strict_import = true
+strict_parameter_checks = true
+strict_primitive_comparisons = true
+# strict_undefined_checks: too many false positives
+
+[tool.isort]
+src_paths = ['tests/python']
+line_length = 120
+combine_as_imports = true
diff --git a/gpt4all-chat/src/main.cpp b/gpt4all-chat/src/main.cpp
index 6aab7b51..c2bdac1e 100644
--- a/gpt4all-chat/src/main.cpp
+++ b/gpt4all-chat/src/main.cpp
@@ -26,6 +26,8 @@
 
 #ifdef Q_OS_WINDOWS
 #   include <windows.h>
+#else
+#   include <signal.h>
 #endif
 
 using namespace Qt::Literals::StringLiterals;
@@ -130,6 +132,17 @@ int main(int argc, char *argv[])
     }
 #endif
 
+#ifndef Q_OS_WINDOWS
+    // handle signals gracefully
+    struct sigaction sa;
+    sa.sa_handler = [](int s) { QCoreApplication::exit(s == SIGINT ? 0 : 1); };
+    sa.sa_flags   = SA_RESETHAND;
+    sigemptyset(&sa.sa_mask);
+    sigaction(SIGINT,  &sa, nullptr);
+    sigaction(SIGTERM, &sa, nullptr);
+    sigaction(SIGHUP,  &sa, nullptr);
+#endif
+
     int res = app.exec();
 
     // Make sure ChatLLM threads are joined before global destructors run.
diff --git a/gpt4all-chat/test-requirements.txt b/gpt4all-chat/test-requirements.txt
new file mode 100644
index 00000000..c15d2916
--- /dev/null
+++ b/gpt4all-chat/test-requirements.txt
@@ -0,0 +1,2 @@
+pytest~=8.3
+requests~=2.32
diff --git a/gpt4all-chat/tests/CMakeLists.txt b/gpt4all-chat/tests/CMakeLists.txt
index ba71959a..1e20a4ec 100644
--- a/gpt4all-chat/tests/CMakeLists.txt
+++ b/gpt4all-chat/tests/CMakeLists.txt
@@ -1,6 +1,6 @@
 include(FetchContent)
 
-find_package(Python3 REQUIRED COMPONENTS Interpreter)
+find_package(Python3 3.12 REQUIRED COMPONENTS Interpreter)
 
 # Google test download and setup
 FetchContent_Declare(
@@ -9,8 +9,10 @@ FetchContent_Declare(
 )
 FetchContent_MakeAvailable(googletest)
 
+configure_file(python/config.py.in "${CMAKE_CURRENT_SOURCE_DIR}/python/config.py")
+
 add_test(NAME ChatPythonTests
-    COMMAND ${Python3_EXECUTABLE} -m pytest ${CMAKE_SOURCE_DIR}/tests/python_tests
+    COMMAND ${Python3_EXECUTABLE} -m pytest --color=yes "${CMAKE_CURRENT_SOURCE_DIR}/python"
 )
 set_tests_properties(ChatPythonTests PROPERTIES
     ENVIRONMENT "CHAT_EXECUTABLE=${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/chat"
@@ -18,8 +20,8 @@ set_tests_properties(ChatPythonTests PROPERTIES
 )
 
 add_executable(gpt4all_tests
-    test_main.cpp
-    basic_test.cpp
+    cpp/test_main.cpp
+    cpp/basic_test.cpp
 )
 
 target_link_libraries(gpt4all_tests PRIVATE gtest gtest_main)
diff --git a/gpt4all-chat/tests/basic_test.cpp b/gpt4all-chat/tests/cpp/basic_test.cpp
similarity index 100%
rename from gpt4all-chat/tests/basic_test.cpp
rename to gpt4all-chat/tests/cpp/basic_test.cpp
diff --git a/gpt4all-chat/tests/test_main.cpp b/gpt4all-chat/tests/cpp/test_main.cpp
similarity index 100%
rename from gpt4all-chat/tests/test_main.cpp
rename to gpt4all-chat/tests/cpp/test_main.cpp
diff --git a/gpt4all-chat/tests/python/__init__.py b/gpt4all-chat/tests/python/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/gpt4all-chat/tests/python/config.py.in b/gpt4all-chat/tests/python/config.py.in
new file mode 100644
index 00000000..661ec1f9
--- /dev/null
+++ b/gpt4all-chat/tests/python/config.py.in
@@ -0,0 +1 @@
+APP_VERSION = '@APP_VERSION@'
diff --git a/gpt4all-chat/tests/python/test_server_api.py b/gpt4all-chat/tests/python/test_server_api.py
new file mode 100644
index 00000000..95c49cac
--- /dev/null
+++ b/gpt4all-chat/tests/python/test_server_api.py
@@ -0,0 +1,87 @@
+import os
+import signal
+import subprocess
+import sys
+import tempfile
+import textwrap
+from pathlib import Path
+from subprocess import CalledProcessError
+from typing import Any, Iterator
+
+import pytest
+import requests
+from urllib3 import Retry
+
+from . import config
+
+
+class Requestor:
+    def __init__(self) -> None:
+        self.session = requests.Session()
+        self.http_adapter = self.session.adapters['http://']
+
+    def get(self, path: str, *, wait: bool = False) -> Any:
+        return self._request('GET', path, wait)
+
+    def _request(self, method: str, path: str, wait: bool) -> Any:
+        if wait:
+            retry = Retry(total=None, connect=10, read=False, status=0, other=0, backoff_factor=.01)
+        else:
+            retry = Retry(total=False)
+        self.http_adapter.max_retries = retry  # type: ignore[attr-defined]
+
+        resp = self.session.request(method, f'http://localhost:4891/v1/{path}')
+        resp.raise_for_status()
+        return resp.json()
+
+
+request = Requestor()
+
+
+@pytest.fixture
+def chat_server_config() -> Iterator[dict[str, str]]:
+    if os.name != 'posix' or sys.platform == 'darwin':
+        pytest.skip('Need non-Apple Unix to use alternate config path')
+
+    with tempfile.TemporaryDirectory(prefix='gpt4all-test') as td:
+        tmpdir = Path(td)
+        xdg_confdir = tmpdir / 'config'
+        app_confdir = xdg_confdir / 'nomic.ai'
+        app_confdir.mkdir(parents=True)
+        with open(app_confdir / 'GPT4All.ini', 'w') as conf:
+            conf.write(textwrap.dedent(f"""\
+                [General]
+                serverChat=true
+
+                [download]
+                lastVersionStarted={config.APP_VERSION}
+
+                [network]
+                isActive=false
+                usageStatsActive=false
+            """))
+        yield dict(
+            os.environ,
+            XDG_CACHE_HOME=str(tmpdir / 'cache'),
+            XDG_DATA_HOME=str(tmpdir / 'share'),
+            XDG_CONFIG_HOME=str(xdg_confdir),
+            APPIMAGE=str(tmpdir),  # hack to bypass SingleApplication
+        )
+
+
+@pytest.fixture
+def chat_server(chat_server_config: dict[str, str]) -> Iterator[None]:
+    chat_executable = Path(os.environ['CHAT_EXECUTABLE']).absolute()
+    with subprocess.Popen(chat_executable, env=chat_server_config) as process:
+        try:
+            yield
+        except:
+            process.kill()
+            raise
+        process.send_signal(signal.SIGINT)
+        if retcode := process.wait():
+            raise CalledProcessError(retcode, process.args)
+
+
+def test_list_models_empty(chat_server: None) -> None:
+    assert request.get('models', wait=True) == {'object': 'list', 'data': []}
diff --git a/gpt4all-chat/tests/python_tests/test_executable.py b/gpt4all-chat/tests/python_tests/test_executable.py
deleted file mode 100644
index cf3cee0a..00000000
--- a/gpt4all-chat/tests/python_tests/test_executable.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import os
-
-import pytest
-
-# test that the chat executable exists
-def test_chat_environment():
-    assert os.path.exists(os.environ['CHAT_EXECUTABLE'])