diff --git a/EvoScientist/langgraph_dev/http.py b/EvoScientist/langgraph_dev/http.py index 6b83bd7..0d9c6f6 100644 --- a/EvoScientist/langgraph_dev/http.py +++ b/EvoScientist/langgraph_dev/http.py @@ -43,7 +43,7 @@ async def get_models(_request: Request) -> JSONResponse: ``discover_ollama_models()`` call, same 1.5-s timeout, same fail-soft semantics (the probe returns ``[]`` on any error, never raises). The TUI's "Custom Ollama model…" sentinel is intentionally - omitted: that's a widget-specific input affordance, not part of + omitted — that's a widget-specific input affordance, not part of the registry surface. ``default`` reflects the deployment's currently-configured fallback diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py index db786d3..e376782 100644 --- a/EvoScientist/llm/patches.py +++ b/EvoScientist/llm/patches.py @@ -729,6 +729,66 @@ def _patch_openai_capture_reasoning_content() -> None: _patch_openai_capture_reasoning_content() +# --------------------------------------------------------------------------- +# Patch (module-level): silence langgraph_api's OpenAPI schema-generation +# warnings for endpoints whose docstrings aren't valid YAML. +# +# Upstream ``langgraph_api.utils.SchemaGenerator.get_schema`` calls +# ``parse_docstring`` (inherited from Starlette's ``BaseSchemaGenerator``) +# on every registered endpoint. When the docstring is prose with stray +# ``:`` characters, ``yaml.safe_load`` raises and upstream logs the +# failure + full traceback at WARNING level. It then falls back to +# ``{"description": docstring}`` — the endpoint still ends up in the +# schema with its prose as the description, just without structured +# ``parameters``/``responses``/``tags`` fields. +# +# The fallback path is fine; the warning + traceback is just noise. And +# it's only triggered for our deploy because mounting any custom Starlette +# app (``EvoScientist/langgraph_dev/http.py``) makes upstream call +# ``update_openapi_spec`` at startup — which iterates EVERY route, +# including upstream's own endpoints whose prose docstrings predate the +# YAML convention. +# +# Fix: wrap ``parse_docstring`` itself and absorb ``yaml.YAMLError`` by +# returning the same fallback shape upstream's except branch produces. +# Non-YAML exceptions are deliberately left to propagate — upstream's +# ``get_schema`` already catches them and logs WARNING + traceback, so +# unexpected failures remain debuggable. Patching ``parse_docstring`` (a +# small, stable method) instead of ``get_schema`` (the larger loop body) +# minimizes our exposure to upstream churn. +# --------------------------------------------------------------------------- +_langgraph_schema_silenced_patched = False + + +def _patch_langgraph_schema_generator_silence_warnings() -> None: + global _langgraph_schema_silenced_patched + if _langgraph_schema_silenced_patched: + return + try: + import langgraph_api.utils as _lgapi_utils + import yaml + + _SchemaGenerator = _lgapi_utils.SchemaGenerator + _orig_parse_docstring = _SchemaGenerator.parse_docstring + + def _patched_parse_docstring(self: Any, func: Any) -> dict[str, Any]: + try: + return _orig_parse_docstring(self, func) + except yaml.YAMLError: + return {"description": getattr(func, "__doc__", None) or ""} + + _SchemaGenerator.parse_docstring = _patched_parse_docstring + _langgraph_schema_silenced_patched = True + except Exception: + # Patches are loader-safe: never crash the import. Silent failure + # here just leaves the upstream warnings visible in deploy logs, + # which is a benign fallback. + pass + + +_patch_langgraph_schema_generator_silence_warnings() + + # --------------------------------------------------------------------------- # Patch (lazy, OpenRouter only): strip OpenAI-Responses encrypted reasoning # items from outgoing assistant messages. diff --git a/tests/test_langgraph_schema_generator_silenced.py b/tests/test_langgraph_schema_generator_silenced.py new file mode 100644 index 0000000..0b856c5 --- /dev/null +++ b/tests/test_langgraph_schema_generator_silenced.py @@ -0,0 +1,152 @@ +"""Regression tests for the langgraph_api SchemaGenerator silencing patch. + +Reproducer: mounting our ``/api/models`` custom Starlette app makes +langgraph_api call ``update_openapi_spec`` at startup, which iterates +EVERY route (ours + upstream's). Endpoints whose docstrings aren't +valid YAML hit a warning + traceback in the deploy log — purely noise, +since the existing fallback path already produces a usable schema +entry. The patch keeps the fallback shape but silences the log spam. +""" + +from __future__ import annotations + +import os + +# ``langgraph_api.config`` reads several required env vars at import +# time via starlette's ``Config(...)`` helper. We don't actually use the +# DB or Redis here — any non-empty string keeps the loader happy. +os.environ.setdefault("DATABASE_URI", "sqlite:///:memory:") +os.environ.setdefault("REDIS_URI", "redis://localhost:6379") + +# Importing patches.py applies the eager module-level monkey-patch. +import langgraph_api.utils as _lgapi_utils + +import EvoScientist.llm.patches as _patches + +# Re-invoke the patch after env vars are set. Required because earlier test +# modules (e.g. test_llm.py) import patches.py *without* DATABASE_URI/ +# REDIS_URI, which makes ``langgraph_api.utils`` fail to import inside the +# patch's bare ``except``; the loader swallows it and the flag stays False +# forever (Python won't re-run module-level code on subsequent imports). +# The patch function is idempotent (early-return on the flag), so calling +# it here is a no-op when the patch already landed and a successful retry +# when the prior import failed. +_patches._patch_langgraph_schema_generator_silence_warnings() + + +class _FakeEndpoint: + """Minimal Starlette-like endpoint info for the schema generator.""" + + def __init__(self, path: str, method: str, func): + self.path = path + self.http_method = method + self.func = func + + +class _DocstringFixture: + """The kinds of docstrings the patched generator must handle.""" + + @staticmethod + def prose_with_colon(): + """Endpoint summary. + + Query params: + id: The thing you want. + """ + + @staticmethod + def valid_yaml(): + """ + summary: A valid YAML docstring. + description: Stays structured. + """ + + @staticmethod + def no_docstring(): + pass + + +def _generator(): + return _lgapi_utils.SchemaGenerator( + {"openapi": "3.1.0", "info": {"title": "test", "version": "0"}} + ) + + +def test_prose_docstring_no_longer_logs_warning(): + """The patched ``parse_docstring`` must silence upstream's structlog + WARNING when ``yaml.safe_load`` fails on a prose docstring. + + Inverts the patch first to prove the fixture actually trips + ``yaml.safe_load`` — without this baseline assertion the test would + pass vacuously if the fixture stopped triggering the failure path + (e.g. if upstream changed how docstrings are pre-processed). + """ + from structlog.testing import capture_logs + + gen = _generator() + endpoint = _FakeEndpoint("/x", "get", _DocstringFixture.prose_with_colon) + gen.get_endpoints = lambda _routes: [endpoint] + + patched_parse = _lgapi_utils.SchemaGenerator.parse_docstring + # Phase 1: baseline. Drop the subclass override so MRO falls through + # to Starlette's BaseSchemaGenerator.parse_docstring, which is what + # production hits before our patch installs. + del _lgapi_utils.SchemaGenerator.parse_docstring + try: + with capture_logs() as baseline_records: + gen.get_schema([]) + finally: + _lgapi_utils.SchemaGenerator.parse_docstring = patched_parse + + baseline_warnings = [r for r in baseline_records if r.get("log_level") == "warning"] + assert any( + "Unable to parse docstring" in r.get("event", "") for r in baseline_warnings + ), "fixture no longer trips parse_docstring — test would pass vacuously" + + # Phase 2: with the patch reinstated, the same call must emit no + # warning records. + with capture_logs() as patched_records: + schema = gen.get_schema([]) + + assert [r for r in patched_records if r.get("log_level") == "warning"] == [] + + # Schema still has the fallback shape — fixture's prose becomes the + # description verbatim (with leading/trailing whitespace from the + # docstring preserved by upstream's fallback path). + entry = schema["paths"]["/x"]["get"] + assert "description" in entry + assert "Query params" in entry["description"] + + +def test_valid_yaml_docstring_keeps_structured_parse(): + """Endpoints with parseable YAML keep their structured metadata — + we only changed the failure branch, not the success path. + """ + gen = _generator() + endpoint = _FakeEndpoint("/y", "get", _DocstringFixture.valid_yaml) + gen.get_endpoints = lambda _routes: [endpoint] + schema = gen.get_schema([]) + entry = schema["paths"]["/y"]["get"] + assert entry.get("summary") == "A valid YAML docstring." + assert entry.get("description") == "Stays structured." + + +def test_no_docstring_still_handled(): + """Endpoints with ``__doc__ = None`` must not raise — fallback uses + empty string for ``description``. + """ + gen = _generator() + endpoint = _FakeEndpoint("/z", "get", _DocstringFixture.no_docstring) + gen.get_endpoints = lambda _routes: [endpoint] + schema = gen.get_schema([]) + entry = schema["paths"]["/z"]["get"] + # Either description="" (fallback path) or structured (if YAML parse + # of None happens to succeed somehow — implementation detail). + # The contract is just "no exception, entry exists". + assert isinstance(entry, dict) + + +def test_patch_flag_set(): + from EvoScientist.llm.patches import _langgraph_schema_silenced_patched + + assert _langgraph_schema_silenced_patched is True