From 4d7fd175a9e812e3c9135fd878612eba58962ef4 Mon Sep 17 00:00:00 2001 From: Alexandr Basiuk Date: Wed, 6 May 2026 14:41:12 +0300 Subject: [PATCH 1/3] fix(agents): auto-title LLM call crashes with metadata=None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /auto-title endpoint passes metadata=None because session-title generation isn't part of an agent invocation and must not show up in Langfuse. But LLMClient._build_langfuse_metadata accessed call_meta.analytics_consent unconditionally, so every auto-title call raised AttributeError. The endpoint's outer try/except hid the failure — every new chat session silently fell back to a 60-char slice of the first user message instead of the LLM-generated 3-6 word title, and logs filled with "auto-title LLM call failed: 'NoneType' object has no attribute analytics_consent". Make metadata optional in acompletion's signature and have _build_langfuse_metadata return None when call_meta is None (same behavior as analytics_consent="off"). Adds regression test. --- backend/app/agents/llm.py | 10 ++++++++-- backend/tests/agents/test_llm.py | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/app/agents/llm.py b/backend/app/agents/llm.py index abd3b64..7f641b7 100644 --- a/backend/app/agents/llm.py +++ b/backend/app/agents/llm.py @@ -100,7 +100,7 @@ async def acompletion( tools: list[dict] | None = None, tool_choice: str | dict | None = None, response_format: dict | None = None, - metadata: LLMCallMetadata, + metadata: LLMCallMetadata | None, model_override: str | None = None, max_tokens: int | None = None, temperature: float | None = None, @@ -465,7 +465,7 @@ def _safe_completion_cost(resp: Any) -> Decimal | None: return None def _build_langfuse_metadata( - self, call_meta: LLMCallMetadata + self, call_meta: LLMCallMetadata | None ) -> dict | None: """Build per-call metadata for the LiteLLM Langfuse callback. @@ -474,6 +474,12 @@ def _build_langfuse_metadata( env vars at app startup by ``app/agents/tracing.py`` (task 013); this method only constructs the trace identifying info. """ + # Some callers (e.g. the auto-title endpoint) intentionally pass no + # metadata — they don't belong to an agent invocation and must never + # show up in Langfuse. Treat that as "tracing off" to avoid an + # AttributeError on call_meta.analytics_consent. + if call_meta is None: + return None if call_meta.analytics_consent == "off": return None if not os.environ.get(_LANGFUSE_PUBLIC_KEY_ENV): diff --git a/backend/tests/agents/test_llm.py b/backend/tests/agents/test_llm.py index 48157d1..cce3589 100644 --- a/backend/tests/agents/test_llm.py +++ b/backend/tests/agents/test_llm.py @@ -335,6 +335,13 @@ def test_langfuse_metadata_off_returns_none(client: LLMClient): assert client._build_langfuse_metadata(meta) is None +def test_langfuse_metadata_none_returns_none(client: LLMClient): + # Auto-title and other ad-hoc LLM calls pass metadata=None because they + # don't belong to an agent invocation. Must not blow up with + # AttributeError on call_meta.analytics_consent. + assert client._build_langfuse_metadata(None) is None + + def test_langfuse_metadata_full_with_env_returns_dict( client: LLMClient, monkeypatch: pytest.MonkeyPatch ): From 948fb8865eb2bd378164b5eee855d901557cf9d5 Mon Sep 17 00:00:00 2001 From: Alexandr Basiuk Date: Wed, 6 May 2026 14:43:54 +0300 Subject: [PATCH 2/3] fix(db): swallow asyncio.CancelledError noise on SSE client disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the frontend aborts a streaming endpoint (tab close, navigation, network blip), uvicorn cancels the ASGI task with asyncio.CancelledError. That hits get_db while it's running session.commit() after the yield. Our `except Exception:` did NOT catch CancelledError (it's a BaseException, not Exception), so: 1. commit() bubbles the CancelledError out of the try. 2. The async-with __aexit__ kicks in and tries to terminate the asyncpg connection. 3. Connection terminate ALSO runs in the cancelled scope, raises CancelledError again — SQLAlchemy logs "Exception terminating connection ..." at ERROR level. 4. Then the original CancelledError bubbles all the way up, uvicorn logs "Exception in ASGI application" with a multi-screen traceback. Net effect: every SSE disconnect produces a wall of red in prod logs even though the cancellation is correct and resources still get cleaned up. Restructure get_db to catch BaseException, do a best-effort rollback (suppressed because the scope may already be torn down), and re-raise. Move commit() into the else branch so it only runs on normal completion. --- backend/app/core/database.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 7c4329a..825b87b 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,3 +1,4 @@ +import contextlib from collections.abc import AsyncGenerator from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -19,7 +20,21 @@ async def get_db() -> AsyncGenerator[AsyncSession]: async with async_session() as session: try: yield session - await session.commit() - except Exception: - await session.rollback() + except BaseException: + # SSE endpoints get cancelled when the frontend aborts (tab close, + # navigation, network blip). uvicorn raises asyncio.CancelledError + # which is a BaseException — not Exception — so a plain + # `except Exception` would miss it and the noisy "Exception + # terminating connection" + "Exception in ASGI application" + # tracebacks would land in prod logs on every disconnect. + # + # Catch BaseException, attempt a best-effort rollback, then + # re-raise so the framework still treats the request as cancelled. + # The rollback itself can also raise CancelledError if the scope + # is already torn down — suppress that too, SQLAlchemy still + # returns the connection to the pool on its own. + with contextlib.suppress(BaseException): + await session.rollback() raise + else: + await session.commit() From 4b00e5d899a7b2ca90b21cc72b589a7f02fbe254 Mon Sep 17 00:00:00 2001 From: Alexandr Basiuk Date: Wed, 6 May 2026 14:52:08 +0300 Subject: [PATCH 3/3] feat(config): accept LANGFUSE_BASE_URL as alias for LANGFUSE_HOST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted Langfuse setups often use LANGFUSE_BASE_URL in their .env (matching the public Langfuse SDK docs in some Docker examples). Our code reads only LANGFUSE_HOST (LiteLLM convention), so a misnamed env var silently disables tracing — `is_langfuse_configured()` returns False, callbacks never register, and operators see no traces with no loud error. Use Pydantic AliasChoices so langfuse_host accepts either env name. LANGFUSE_HOST wins when both are set (canonical first). Document the alias in .env.example and README. Adds two regression tests that instantiate a fresh Settings() with each env-var combination. --- .env.example | 6 ++++++ README.md | 3 ++- backend/app/core/config.py | 11 +++++++++-- backend/tests/agents/test_tracing.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 3137714..fc6c55a 100644 --- a/.env.example +++ b/.env.example @@ -50,9 +50,15 @@ AGENTS_SECRET_KEY= # at startup and routes per-call telemetry. Per-call gating is governed by # the workspace's analytics_consent (off / errors_only / full). Leave blank # to disable tracing entirely. +# +# LANGFUSE_HOST is the canonical name (matches the LiteLLM / Langfuse SDK +# convention). LANGFUSE_BASE_URL is accepted as an alias so a self-hosted +# setup that uses the alternative naming still wires up tracing. If both +# are set, LANGFUSE_HOST wins. LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= LANGFUSE_HOST= +# LANGFUSE_BASE_URL= # alias for LANGFUSE_HOST # Agent invocation rate limits — operator-level (not per-workspace). Defaults # below are 10× the original spec. Override only if you need to throttle diff --git a/README.md b/README.md index 17b28d1..96e4221 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,8 @@ BACKEND_CORS_ORIGINS=http://localhost:5173 # Optional — Langfuse tracing for agent calls (per-workspace consent gates each call). LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= -LANGFUSE_HOST= +LANGFUSE_HOST= # canonical, matches LiteLLM/Langfuse SDK +# LANGFUSE_BASE_URL= # accepted as alias for LANGFUSE_HOST ``` ### ⚠️ Required for AI agents: `AGENTS_SECRET_KEY` diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 275c858..6780bb4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,4 +1,4 @@ -from pydantic import SecretStr +from pydantic import AliasChoices, Field, SecretStr from pydantic_settings import BaseSettings @@ -48,7 +48,14 @@ class Settings(BaseSettings): # convention and the langfuse/skills setup pattern. langfuse_public_key: SecretStr | None = None langfuse_secret_key: SecretStr | None = None - langfuse_host: str | None = None + # Accept either LANGFUSE_HOST (LiteLLM/SDK convention) or + # LANGFUSE_BASE_URL (a common typo / alternative naming) so a misnamed + # env var doesn't silently disable tracing. Both are documented in + # .env.example. + langfuse_host: str | None = Field( + default=None, + validation_alias=AliasChoices("LANGFUSE_HOST", "LANGFUSE_BASE_URL"), + ) # Agent invocation rate limits — operator-level, not per-workspace. # Defaults are 10× the original spec defaults (which were 600/h, 6000/d, diff --git a/backend/tests/agents/test_tracing.py b/backend/tests/agents/test_tracing.py index ebaf62a..69e8244 100644 --- a/backend/tests/agents/test_tracing.py +++ b/backend/tests/agents/test_tracing.py @@ -128,6 +128,34 @@ def test_is_langfuse_configured_false_when_all_missing( assert tracing.is_langfuse_configured() is False +# --------------------------------------------------------------------------- +# Env-var alias: LANGFUSE_HOST is canonical, LANGFUSE_BASE_URL is accepted +# as a fallback so a misnamed env var doesn't silently disable tracing. +# --------------------------------------------------------------------------- + + +def test_settings_picks_up_langfuse_base_url_alias( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.delenv("LANGFUSE_HOST", raising=False) + monkeypatch.setenv("LANGFUSE_BASE_URL", "https://lf.example.test") + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-x") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-x") + fresh = config_module.Settings() + assert fresh.langfuse_host == "https://lf.example.test" + + +def test_settings_prefers_langfuse_host_over_base_url( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("LANGFUSE_HOST", "https://canonical.example.test") + monkeypatch.setenv("LANGFUSE_BASE_URL", "https://alias.example.test") + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-x") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-x") + fresh = config_module.Settings() + assert fresh.langfuse_host == "https://canonical.example.test" + + # --------------------------------------------------------------------------- # setup_litellm_callbacks # ---------------------------------------------------------------------------