diff --git a/src/notewise/_constants.py b/src/notewise/_constants.py index 0ea456b..c097135 100644 --- a/src/notewise/_constants.py +++ b/src/notewise/_constants.py @@ -25,7 +25,7 @@ BOOL_SETTING_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"}) BOOL_SETTING_FALSY_VALUES = frozenset({"0", "false", "no", "off"}) PYDANTIC_RESPONSE_USAGE_WARNING_PATTERN = ( - r"(?s)^Pydantic serializer warnings:.*ResponseAPIUsage" + r"(?s)^Pydantic serializer warnings:.*(ResponseAPIUsage|ResponsesAPIResponse)" ) GITHUB_REPOSITORY_OWNER = "whoisjayd" GITHUB_REPOSITORY_NAME = "notewise" diff --git a/src/notewise/llm/provider.py b/src/notewise/llm/provider.py index edcf86b..a21fa52 100644 --- a/src/notewise/llm/provider.py +++ b/src/notewise/llm/provider.py @@ -50,6 +50,10 @@ def suppress_litellm_noise() -> None: verbose_logger.propagate = False for handler in list(verbose_logger.handlers): verbose_logger.removeHandler(handler) + if not verbose_logger.handlers: + # Avoid Python's `logging.lastResort` handler printing LiteLLM's + # non-blocking internal logging exceptions to stderr. + verbose_logger.addHandler(logging.NullHandler()) warnings.filterwarnings( "ignore", message=PYDANTIC_RESPONSE_USAGE_WARNING_PATTERN, diff --git a/tests/unit/llm/test_providers.py b/tests/unit/llm/test_providers.py index d453a38..b1fd4d5 100644 --- a/tests/unit/llm/test_providers.py +++ b/tests/unit/llm/test_providers.py @@ -640,6 +640,29 @@ def test_suppress_litellm_noise_suppresses_response_usage_warning(self): finally: warnings.filters[:] = original_filters + def test_suppress_litellm_noise_suppresses_response_payload_warning(self): + """LiteLLM's Responses response serializer warnings should stay off the TTY.""" + original_filters = warnings.filters[:] + try: + suppress_litellm_noise() + + with warnings.catch_warnings(record=True) as caught: + warnings.warn_explicit( + "Pydantic serializer warnings:\n" + " PydanticSerializationUnexpectedValue(" + "Expected `ResponsesAPIResponse` - serialized value may not be " + "as expected [field_name='response', input_value={}, " + "input_type=dict])", + UserWarning, + filename="pydantic/main.py", + lineno=464, + module="pydantic.main", + ) + + assert caught == [] + finally: + warnings.filters[:] = original_filters + def test_suppress_litellm_noise_sets_verbose_logger_level(self): """LiteLLM runtime should not attach terminal handlers.""" runtime = MagicMock() @@ -653,6 +676,32 @@ def test_suppress_litellm_noise_sets_verbose_logger_level(self): assert runtime.verbose_logger.propagate is False runtime.verbose_logger.removeHandler.assert_called_once() + def test_suppress_litellm_noise_installs_null_handler(self): + """LiteLLM's verbose logger should not fall back to logging.lastResort.""" + logger_name = "LiteLLM-test-nullhandler" + litellm_logger = logging.getLogger(logger_name) + original_handlers = list(litellm_logger.handlers) + original_propagate = litellm_logger.propagate + original_level = litellm_logger.level + try: + litellm_logger.handlers.clear() + litellm_logger.propagate = False + litellm_logger.setLevel(logging.NOTSET) + + runtime = SimpleNamespace(verbose_logger=litellm_logger) + with patch("notewise.llm.provider.litellm", runtime): + suppress_litellm_noise() + + assert litellm_logger.propagate is False + assert any( + isinstance(handler, logging.NullHandler) + for handler in litellm_logger.handlers + ) + finally: + litellm_logger.handlers[:] = original_handlers + litellm_logger.propagate = original_propagate + litellm_logger.setLevel(original_level) + def test_extract_usage_handles_invalid_values(self): """Non-numeric usage payloads should fail closed to zero values.""" provider = LLMProvider("gpt-4o")