diff --git a/CHANGELOG.md b/CHANGELOG.md index f5304d014..072cc1edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Fixed +- **proxy/windows:** support Windows selector-event-loop startup on uvicorn versions older than 0.36. Newer uvicorn accepts `loop="asyncio:SelectorEventLoop"` as a custom loop-factory import path, but older versions treat it as an unknown built-in loop name and raise `KeyError`. Windows now sets `WindowsSelectorEventLoopPolicy` for those older versions instead of passing an unsupported `loop` value ([#1650](https://github.com/headroomlabs-ai/headroom/issues/1650), [#1621](https://github.com/headroomlabs-ai/headroom/issues/1621)). - **backends/litellm:** preserve `cache_control` on `tool_result` blocks when converting Anthropic messages for the Bedrock Converse path, and complete streaming cache-stats surfacing in `stream_message`. Complements [#1390](https://github.com/headroomlabs-ai/headroom/pull/1390), which preserves `cache_control` on the system prompt and plain text blocks but explicitly leaves `tool_result` out of scope — in agent loops the moving cache breakpoint lands on the tail `tool_result` far more often than on the system prompt, so that gap left most of the caching benefit on the table. Separately, `stream_message` never requested `stream_options.include_usage`, so LiteLLM/Bedrock never returned a usage chunk over SSE and `cache_read_input_tokens`/`cache_creation_input_tokens` always reported 0 downstream even when the prompt cache was genuinely engaged; the terminal `message_delta` now carries the real cache values captured from the trailing usage chunk once the stream completes. - **shared_context:** `SharedContext.put` no longer evicts an unrelated entry when it merely updates a key that is already cached at capacity — same defect class fixed for `SemanticCache` in [#2094](https://github.com/headroomlabs-ai/headroom/pull/2094). - **compress:** stop mutating the caller's `CompressConfig`. `compress(config=my_cfg, protect_recent=0, target_ratio=0.2)` used to write those kwargs onto `my_cfg`, so a shared per-agent config was silently rewritten by every request that overrode a single option. diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index 578556e1c..454d6c3cc 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -2967,7 +2967,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: def _is_recent_request_number(value: Any) -> bool: return ( - isinstance(value, (int, float)) + isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(float(value)) ) @@ -4439,6 +4439,27 @@ def _get_code_aware_banner_status(config: ProxyConfig) -> str: return "DISABLED (install headroom-ai[code] to enable)" +def _configure_windows_uvicorn_loop(uvicorn_kwargs: dict[str, Any]) -> None: + """Select a Windows-safe asyncio loop for uvicorn across versions. + + ProactorEventLoop can close the listening socket on transient AcceptEx + failures (for example WinError 64 from keep-alive RSTs). SelectorEventLoop + keeps accept errors scoped to the connection. + + uvicorn >= 0.36 resolves ``asyncio:SelectorEventLoop`` as a custom loop + factory import path. Older uvicorn only accepts built-in loop names and + raises KeyError for that value, so we set the selector policy instead. + """ + import uvicorn as _uvicorn + + if hasattr(_uvicorn.config.Config, "get_loop_factory"): + uvicorn_kwargs["loop"] = "asyncio:SelectorEventLoop" + else: + policy_cls = getattr(asyncio, "WindowsSelectorEventLoopPolicy", None) + if policy_cls is not None: + asyncio.set_event_loop_policy(policy_cls()) + + def run_server( config: ProxyConfig | None = None, workers: int = 1, @@ -4542,10 +4563,7 @@ def run_server( app_target: Any uvicorn_kwargs: dict[str, Any] = {} if sys.platform == "win32": - # ProactorEventLoop can close the listening socket on transient - # AcceptEx failures (for example WinError 64 from keep-alive RSTs). - # The selector loop keeps accept errors scoped to the connection. - uvicorn_kwargs["loop"] = "asyncio:SelectorEventLoop" + _configure_windows_uvicorn_loop(uvicorn_kwargs) if workers > 1: # CompressionCache and PrefixTracker are always per-worker instance vars. # Python CompressionStore defaults to InMemoryBackend (per-process), so diff --git a/tests/test_proxy_scalability.py b/tests/test_proxy_scalability.py index 1cd6a44a7..d0184a2a9 100644 --- a/tests/test_proxy_scalability.py +++ b/tests/test_proxy_scalability.py @@ -6,6 +6,7 @@ These tests verify connection pooling, HTTP/2, and worker configuration. import asyncio import json import os +from typing import Any from unittest.mock import patch import httpx @@ -284,23 +285,64 @@ class TestWorkerConfiguration: os.environ.pop(_MULTI_WORKER_CONFIG_ENV, None) def test_run_server_uses_selector_loop_on_windows(self, monkeypatch): + import builtins + + import uvicorn + from uvicorn.config import Config + from headroom.proxy import server as server_mod from headroom.proxy.models import ProxyConfig captured = {} + policy_calls: list[Any] = [] + real_hasattr = builtins.hasattr + + class _FakeSelectorPolicy: + pass def fake_run(app, **kwargs): captured["app"] = app captured["kwargs"] = kwargs + def fake_set_policy(policy): + policy_calls.append(policy) + + def fake_hasattr(obj, name): + if obj is Config and name == "get_loop_factory": + return fake_hasattr.use_new_api + return real_hasattr(obj, name) + + fake_hasattr.use_new_api = True + + monkeypatch.setattr(builtins, "hasattr", fake_hasattr) monkeypatch.setattr(server_mod.sys, "platform", "win32") monkeypatch.setattr(server_mod, "create_app", lambda config: "app") + monkeypatch.setattr(server_mod.asyncio, "set_event_loop_policy", fake_set_policy) + monkeypatch.setattr( + server_mod.asyncio, + "WindowsSelectorEventLoopPolicy", + _FakeSelectorPolicy, + raising=False, + ) with patch("headroom.proxy.server.uvicorn.run", fake_run): server_mod.run_server(ProxyConfig(), print_banner=False) assert captured["app"] == "app" assert captured["kwargs"]["loop"] == "asyncio:SelectorEventLoop" + assert policy_calls == [] + + fake_hasattr.use_new_api = False + captured.clear() + policy_calls.clear() + + with patch("headroom.proxy.server.uvicorn.run", fake_run): + server_mod.run_server(ProxyConfig(), print_banner=False) + + assert "loop" not in captured["kwargs"] + assert len(policy_calls) == 1 + assert isinstance(policy_calls[0], _FakeSelectorPolicy) + _ = uvicorn # keep import for parity with runtime module path def test_run_server_keeps_default_loop_off_windows(self, monkeypatch): from headroom.proxy import server as server_mod