From 5ae4aef30e3efd4dd46968a6daf5f0c657966bb6 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 7 Sep 2026 09:54:28 +0800 Subject: [PATCH] fix(llm): disable brotli for catalog fetch to prevent native crash (#1099, #1101) models.dev is served by Cloudflare with brotli compression when the client advertises it, and brotli is a required dependency here, so aiohttp always negotiates br. A corrupted br stream can crash the native decoder with a Windows access violation (a Python-level exception handler cannot catch it), or produce garbage bytes. Send an explicit "Accept-Encoding: gzip, deflate" header on the model catalog and Ollama model-list requests so the server never returns brotli. zlib handles corrupt gzip data by raising ContentEncodingError (an aiohttp.ClientError subclass), which the existing handlers already catch and degrade to a warning with an empty-catalog fallback. --- py/services/llm_service.py | 12 ++++++++++-- tests/services/test_llm_service.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/py/services/llm_service.py b/py/services/llm_service.py index 41ef7870..04ccb346 100644 --- a/py/services/llm_service.py +++ b/py/services/llm_service.py @@ -34,6 +34,14 @@ _model_output_limits: Dict[str, Dict[str, int]] = {} _CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30) +# Cloudflare serves brotli when the client advertises it, and brotli is a +# required dependency here — a corrupted br stream can crash the native +# decoder with a Windows access violation (issue #1099). Request gzip +# instead; zlib decompression is not affected and corrupt gzip data only +# raises ContentEncodingError (an aiohttp.ClientError subclass), which the +# exception handlers below already catch. +_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"} + async def _load_model_catalog() -> Dict[str, List[str]]: """Fetch and parse the model catalog. @@ -53,7 +61,7 @@ async def _load_model_catalog() -> Dict[str, List[str]]: try: async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session: - async with session.get(_MODEL_CATALOG_URL) as resp: + async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp: if resp.status != 200: logger.warning("Model catalog returned HTTP %s", resp.status) return _catalog_cache or {} @@ -126,7 +134,7 @@ async def fetch_ollama_models(api_base: str) -> List[str]: url = f"{api_base.rstrip('/')}/models" try: async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session: - async with session.get(url) as resp: + async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp: if resp.status != 200: logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base) return [] diff --git a/tests/services/test_llm_service.py b/tests/services/test_llm_service.py index 883c10d3..9c00c5c1 100644 --- a/tests/services/test_llm_service.py +++ b/tests/services/test_llm_service.py @@ -322,8 +322,12 @@ class MockGetSession: def __init__(self, response): self._response = response + self.last_url = None + self.last_headers = None - def get(self, url): + def get(self, url, headers=None): + self.last_url = url + self.last_headers = headers return self._response async def __aenter__(self): @@ -373,3 +377,27 @@ class TestModelCatalog: models = await fetch_ollama_models("http://localhost:11434/v1") assert models == [] + + @pytest.mark.asyncio + async def test_catalog_request_disables_brotli_encoding(self): + """The catalog request must not advertise br — a corrupt brotli stream + can crash the native decoder (Windows access violation, issue #1099).""" + response = MockResponse(200, json_data={}) + session = MockGetSession(response) + + with mock.patch("aiohttp.ClientSession", return_value=session): + await llm_module._load_model_catalog() + + assert session.last_headers == {"Accept-Encoding": "gzip, deflate"} + + @pytest.mark.asyncio + async def test_ollama_request_disables_brotli_encoding(self): + """The Ollama models request must not advertise br either.""" + response = MockResponse(200, json_data={"data": [{"id": "llama3"}]}) + session = MockGetSession(response) + + with mock.patch("aiohttp.ClientSession", return_value=session): + models = await fetch_ollama_models("http://localhost:11434/v1") + + assert models == ["llama3"] + assert session.last_headers == {"Accept-Encoding": "gzip, deflate"}