From ca4f8fbe86808f7bb2a52662a1d4a57fdd5d875b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 26 Mar 2026 18:29:51 +0000 Subject: [PATCH 1/8] chore: move braintrust-langchain source into braintrust package No content changes in this commit. --- .../src/braintrust/integrations/langchain}/callbacks.py | 0 .../integrations/langchain}/cassettes/test_async_langchain_invoke | 0 .../integrations/langchain}/cassettes/test_chain_with_memory | 0 .../integrations/langchain}/cassettes/test_global_handler | 0 .../langchain}/cassettes/test_langchain_anthropic_integration | 0 .../langchain}/cassettes/test_langgraph_state_management | 0 .../braintrust/integrations/langchain}/cassettes/test_llm_calls | 0 .../integrations/langchain}/cassettes/test_parallel_execution | 0 .../integrations/langchain}/cassettes/test_prompt_caching_tokens | 0 .../integrations/langchain}/cassettes/test_streaming_ttft | 0 .../braintrust/integrations/langchain}/cassettes/test_tool_usage | 0 .../src/braintrust/integrations/langchain}/context.py | 0 .../tests => py/src/braintrust/integrations/langchain}/helpers.py | 0 .../src/braintrust/integrations/langchain}/test_anthropic.py | 0 .../src/braintrust/integrations/langchain}/test_callbacks.py | 0 .../src/braintrust/integrations/langchain}/test_context.py | 0 16 files changed, 0 insertions(+), 0 deletions(-) rename {integrations/langchain-py/src/braintrust_langchain => py/src/braintrust/integrations/langchain}/callbacks.py (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_async_langchain_invoke (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_chain_with_memory (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_global_handler (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_langchain_anthropic_integration (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_langgraph_state_management (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_llm_calls (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_parallel_execution (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_prompt_caching_tokens (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_streaming_ttft (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/cassettes/test_tool_usage (100%) rename {integrations/langchain-py/src/braintrust_langchain => py/src/braintrust/integrations/langchain}/context.py (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/helpers.py (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/test_anthropic.py (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/test_callbacks.py (100%) rename {integrations/langchain-py/src/tests => py/src/braintrust/integrations/langchain}/test_context.py (100%) diff --git a/integrations/langchain-py/src/braintrust_langchain/callbacks.py b/py/src/braintrust/integrations/langchain/callbacks.py similarity index 100% rename from integrations/langchain-py/src/braintrust_langchain/callbacks.py rename to py/src/braintrust/integrations/langchain/callbacks.py diff --git a/integrations/langchain-py/src/tests/cassettes/test_async_langchain_invoke b/py/src/braintrust/integrations/langchain/cassettes/test_async_langchain_invoke similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_async_langchain_invoke rename to py/src/braintrust/integrations/langchain/cassettes/test_async_langchain_invoke diff --git a/integrations/langchain-py/src/tests/cassettes/test_chain_with_memory b/py/src/braintrust/integrations/langchain/cassettes/test_chain_with_memory similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_chain_with_memory rename to py/src/braintrust/integrations/langchain/cassettes/test_chain_with_memory diff --git a/integrations/langchain-py/src/tests/cassettes/test_global_handler b/py/src/braintrust/integrations/langchain/cassettes/test_global_handler similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_global_handler rename to py/src/braintrust/integrations/langchain/cassettes/test_global_handler diff --git a/integrations/langchain-py/src/tests/cassettes/test_langchain_anthropic_integration b/py/src/braintrust/integrations/langchain/cassettes/test_langchain_anthropic_integration similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_langchain_anthropic_integration rename to py/src/braintrust/integrations/langchain/cassettes/test_langchain_anthropic_integration diff --git a/integrations/langchain-py/src/tests/cassettes/test_langgraph_state_management b/py/src/braintrust/integrations/langchain/cassettes/test_langgraph_state_management similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_langgraph_state_management rename to py/src/braintrust/integrations/langchain/cassettes/test_langgraph_state_management diff --git a/integrations/langchain-py/src/tests/cassettes/test_llm_calls b/py/src/braintrust/integrations/langchain/cassettes/test_llm_calls similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_llm_calls rename to py/src/braintrust/integrations/langchain/cassettes/test_llm_calls diff --git a/integrations/langchain-py/src/tests/cassettes/test_parallel_execution b/py/src/braintrust/integrations/langchain/cassettes/test_parallel_execution similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_parallel_execution rename to py/src/braintrust/integrations/langchain/cassettes/test_parallel_execution diff --git a/integrations/langchain-py/src/tests/cassettes/test_prompt_caching_tokens b/py/src/braintrust/integrations/langchain/cassettes/test_prompt_caching_tokens similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_prompt_caching_tokens rename to py/src/braintrust/integrations/langchain/cassettes/test_prompt_caching_tokens diff --git a/integrations/langchain-py/src/tests/cassettes/test_streaming_ttft b/py/src/braintrust/integrations/langchain/cassettes/test_streaming_ttft similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_streaming_ttft rename to py/src/braintrust/integrations/langchain/cassettes/test_streaming_ttft diff --git a/integrations/langchain-py/src/tests/cassettes/test_tool_usage b/py/src/braintrust/integrations/langchain/cassettes/test_tool_usage similarity index 100% rename from integrations/langchain-py/src/tests/cassettes/test_tool_usage rename to py/src/braintrust/integrations/langchain/cassettes/test_tool_usage diff --git a/integrations/langchain-py/src/braintrust_langchain/context.py b/py/src/braintrust/integrations/langchain/context.py similarity index 100% rename from integrations/langchain-py/src/braintrust_langchain/context.py rename to py/src/braintrust/integrations/langchain/context.py diff --git a/integrations/langchain-py/src/tests/helpers.py b/py/src/braintrust/integrations/langchain/helpers.py similarity index 100% rename from integrations/langchain-py/src/tests/helpers.py rename to py/src/braintrust/integrations/langchain/helpers.py diff --git a/integrations/langchain-py/src/tests/test_anthropic.py b/py/src/braintrust/integrations/langchain/test_anthropic.py similarity index 100% rename from integrations/langchain-py/src/tests/test_anthropic.py rename to py/src/braintrust/integrations/langchain/test_anthropic.py diff --git a/integrations/langchain-py/src/tests/test_callbacks.py b/py/src/braintrust/integrations/langchain/test_callbacks.py similarity index 100% rename from integrations/langchain-py/src/tests/test_callbacks.py rename to py/src/braintrust/integrations/langchain/test_callbacks.py diff --git a/integrations/langchain-py/src/tests/test_context.py b/py/src/braintrust/integrations/langchain/test_context.py similarity index 100% rename from integrations/langchain-py/src/tests/test_context.py rename to py/src/braintrust/integrations/langchain/test_context.py From 27ce79d3f067e6b5cb4a9c47ac3ca0a3aacb46e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 26 Mar 2026 18:30:24 +0000 Subject: [PATCH 2/8] feat: adapt braintrust-langchain for braintrust package --- .../src/braintrust_langchain/__init__.py | 28 ++- py/noxfile.py | 24 ++- py/requirements-optional.txt | 13 +- .../integrations/langchain/__init__.py | 20 +++ .../integrations/langchain/callbacks.py | 85 +++++---- .../integrations/langchain/context.py | 11 +- .../integrations/langchain/helpers.py | 47 ++--- .../integrations/langchain/test_anthropic.py | 62 +++++-- .../integrations/langchain/test_callbacks.py | 162 +++++++----------- .../integrations/langchain/test_context.py | 58 ++++--- py/src/braintrust/wrappers/langchain.py | 9 +- 11 files changed, 294 insertions(+), 225 deletions(-) create mode 100644 py/src/braintrust/integrations/langchain/__init__.py diff --git a/integrations/langchain-py/src/braintrust_langchain/__init__.py b/integrations/langchain-py/src/braintrust_langchain/__init__.py index 2feeb7bc..284bfb60 100644 --- a/integrations/langchain-py/src/braintrust_langchain/__init__.py +++ b/integrations/langchain-py/src/braintrust_langchain/__init__.py @@ -1,4 +1,26 @@ -from .callbacks import BraintrustCallbackHandler -from .context import set_global_handler +""" +DEPRECATED: braintrust-langchain is now part of the main braintrust package. -__all__ = ["BraintrustCallbackHandler", "set_global_handler"] +Install `braintrust` and use `from braintrust.integrations.langchain import BraintrustCallbackHandler` instead. +This package now re-exports from `braintrust.integrations.langchain` for backward compatibility. +""" + +import warnings + +warnings.warn( + "braintrust-langchain is deprecated. The LangChain integration is now included in the main " + "'braintrust' package. Use 'from braintrust.integrations.langchain import BraintrustCallbackHandler' " + "instead. This package will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) + +# Re-export public API from the new location for backward compatibility +from braintrust.integrations.langchain import ( # noqa: E402, F401 + BraintrustCallbackHandler, + BraintrustTracer, + clear_global_handler, + set_global_handler, +) + +__all__ = ["BraintrustCallbackHandler", "set_global_handler", "clear_global_handler", "BraintrustTracer"] diff --git a/py/noxfile.py b/py/noxfile.py index 7a79ab64..0e372e7c 100644 --- a/py/noxfile.py +++ b/py/noxfile.py @@ -100,6 +100,7 @@ def _pinned_python_version(): GENAI_VERSIONS = (LATEST,) DSPY_VERSIONS = (LATEST,) GOOGLE_ADK_VERSIONS = (LATEST, "1.14.1") +LANGCHAIN_VERSIONS = (LATEST, "0.3.28") # temporalio 1.19.0+ requires Python >= 3.10; skip Python 3.9 entirely TEMPORAL_VERSIONS = (LATEST, "1.20.0", "1.19.0") PYTEST_VERSIONS = (LATEST, "8.4.2") @@ -202,6 +203,24 @@ def test_google_adk(session, version): _run_core_tests(session) +@nox.session() +@nox.parametrize("version", LANGCHAIN_VERSIONS, ids=LANGCHAIN_VERSIONS) +def test_langchain(session, version): + """Test LangChain integration.""" + # langchain requires Python >= 3.10 + if sys.version_info < (3, 10): + session.skip("langchain requires Python >= 3.10") + _install_test_deps(session) + _install(session, "langchain-core", version) + _install(session, "langchain-openai") + _install(session, "langchain-anthropic") + _install(session, "langgraph") + _run_tests(session, f"{INTEGRATION_DIR}/langchain/test_callbacks.py") + _run_tests(session, f"{INTEGRATION_DIR}/langchain/test_context.py") + _run_tests(session, f"{INTEGRATION_DIR}/langchain/test_anthropic.py") + _run_core_tests(session) + + @nox.session() @nox.parametrize("version", OPENAI_VERSIONS, ids=OPENAI_VERSIONS) def test_openai(session, version): @@ -336,8 +355,9 @@ def pylint(session): session.install("pydantic_ai>=1.10.0") session.install("google-adk") session.install("opentelemetry.instrumentation.openai") - # langsmith is needed for the wrapper module but not in VENDOR_PACKAGES - session.install("langsmith") + # langsmith is needed for the langsmith_wrapper module but not in VENDOR_PACKAGES + # langchain-core, langchain-openai, langchain-anthropic, and tenacity are needed for the langchain integration + session.install("langsmith", "langchain-core", "langchain-openai", "langchain-anthropic", "tenacity") result = session.run("git", "ls-files", "**/*.py", silent=True, log=False) files = [path for path in result.strip().splitlines() if path not in GENERATED_LINT_EXCLUDES] diff --git a/py/requirements-optional.txt b/py/requirements-optional.txt index 1fdb21fd..c84393f1 100644 --- a/py/requirements-optional.txt +++ b/py/requirements-optional.txt @@ -1,9 +1,12 @@ -anthropic==0.84.0 -openai==2.24.0 -pydantic_ai==1.66.0 agno==2.5.7 -google-genai==1.66.0 -google-adk==1.14.1 +anthropic==0.84.0 dspy==3.1.3 +google-adk==1.14.1 +google-genai==1.66.0 +langchain-anthropic==1.4.0 +langchain-core==1.2.22 +langchain-openai==1.1.12 langsmith==0.7.12 litellm==1.82.0 +openai==2.24.0 +pydantic_ai==1.66.0 diff --git a/py/src/braintrust/integrations/langchain/__init__.py b/py/src/braintrust/integrations/langchain/__init__.py new file mode 100644 index 00000000..982c4c7a --- /dev/null +++ b/py/src/braintrust/integrations/langchain/__init__.py @@ -0,0 +1,20 @@ +import logging + +from braintrust.integrations.langchain.callbacks import BraintrustCallbackHandler +from braintrust.integrations.langchain.context import clear_global_handler, set_global_handler + + +__all__ = ["BraintrustCallbackHandler", "set_global_handler", "clear_global_handler", "BraintrustTracer"] + +_logger = logging.getLogger(__name__) + + +class BraintrustTracer(BraintrustCallbackHandler): + """Deprecated: use BraintrustCallbackHandler instead.""" + + def __init__(self, *args, **kwargs): + _logger.warning( + "BraintrustTracer is deprecated, use BraintrustCallbackHandler instead. " + "Update your imports to: from braintrust.integrations.langchain import BraintrustCallbackHandler" + ) + super().__init__(*args, **kwargs) diff --git a/py/src/braintrust/integrations/langchain/callbacks.py b/py/src/braintrust/integrations/langchain/callbacks.py index 016a1268..31014f24 100644 --- a/py/src/braintrust/integrations/langchain/callbacks.py +++ b/py/src/braintrust/integrations/langchain/callbacks.py @@ -14,17 +14,25 @@ import braintrust from braintrust import NOOP_SPAN, Logger, Span, SpanAttributes, SpanTypeAttribute, current_span, init_logger from braintrust.version import VERSION as sdk_version -from langchain_core.agents import AgentAction, AgentFinish -from langchain_core.callbacks.base import BaseCallbackHandler -from langchain_core.documents import Document -from langchain_core.messages import BaseMessage -from langchain_core.outputs.llm_result import LLMResult -from tenacity import RetryCallState from typing_extensions import NotRequired -from braintrust_langchain.version import version -_logger = logging.getLogger("braintrust_langchain") +try: + from langchain_core.agents import AgentAction, AgentFinish + from langchain_core.callbacks.base import BaseCallbackHandler + from langchain_core.documents import Document + from langchain_core.messages import BaseMessage + from langchain_core.outputs.llm_result import LLMResult + from tenacity import RetryCallState +except ImportError: + raise ImportError( + "langchain-core and tenacity are required to use BraintrustCallbackHandler. " + "Install them with: pip install langchain-core tenacity" + ) + +_logger = logging.getLogger("braintrust.wrappers.langchain") + +_INTEGRATION_NAME = "langchain-py" class LogEvent(TypedDict): @@ -75,7 +83,7 @@ def _start_span( set_current: bool | None = None, parent: str | None = None, event: LogEvent | None = None, - ) -> Any: + ) -> Span | None: if run_id in self.spans: # XXX: See graph test case of an example where this _may_ be intended. _logger.warning(f"Span already exists for run_id {run_id} (this is likely a bug)") @@ -108,8 +116,7 @@ def _start_span( "run_id": run_id, "parent_run_id": parent_run_id, "braintrust": { - "integration_name": "langchain-py", - "integration_version": version, + "integration_name": _INTEGRATION_NAME, "sdk_version": sdk_version, "language": "python", }, @@ -158,7 +165,7 @@ def _end_span( metadata: Mapping[str, Any] | None = None, metrics: Mapping[str, int | float] | None = None, dataset_record_id: str | None = None, - ) -> Any: + ) -> None: if run_id not in self.spans: return @@ -207,7 +214,7 @@ def on_llm_error( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, # TODO: response= - ) -> Any: + ) -> None: self._end_span(run_id, error=str(error), metadata={**kwargs}) self._start_times.pop(run_id, None) @@ -221,7 +228,7 @@ def on_chain_error( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, # TODO: some metadata - ) -> Any: + ) -> None: self._end_span(run_id, error=str(error), metadata={**kwargs}) def on_tool_error( @@ -231,7 +238,7 @@ def on_tool_error( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._end_span(run_id, error=str(error), metadata={**kwargs}) def on_retriever_error( @@ -241,7 +248,7 @@ def on_retriever_error( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._end_span(run_id, error=str(error), metadata={**kwargs}) # Agent Methods @@ -252,7 +259,7 @@ def on_agent_action( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._start_span( parent_run_id, run_id, @@ -268,7 +275,7 @@ def on_agent_finish( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._end_span(run_id, output=finish, metadata={**kwargs}) def on_chain_start( @@ -282,7 +289,7 @@ def on_chain_start( name: str | None = None, metadata: dict[str, Any] | None = None, **kwargs: Any, - ) -> Any: + ) -> None: tags = tags or [] # avoids extra logs that seem not as useful esp. with langgraph @@ -323,7 +330,7 @@ def on_chain_end( parent_run_id: UUID | None = None, tags: list[str] | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._end_span(run_id, output=outputs, tags=tags, metadata={**kwargs}) def on_llm_start( @@ -337,7 +344,7 @@ def on_llm_start( metadata: dict[str, Any] | None = None, name: str | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._start_times[run_id] = time.perf_counter() self._first_token_times.pop(run_id, None) self._ttft_ms.pop(run_id, None) @@ -372,7 +379,7 @@ def on_chat_model_start( name: str | None = None, invocation_params: dict[str, Any] | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._start_times[run_id] = time.perf_counter() self._first_token_times.pop(run_id, None) self._ttft_ms.pop(run_id, None) @@ -406,7 +413,7 @@ def on_llm_end( parent_run_id: UUID | None = None, tags: list[str] | None = None, **kwargs: Any, - ) -> Any: + ) -> None: if run_id not in self.spans: return @@ -444,7 +451,7 @@ def on_tool_start( inputs: dict[str, Any] | None = None, name: str | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._start_span( parent_run_id, run_id, @@ -472,7 +479,7 @@ def on_tool_end( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._end_span(run_id, output=output, metadata={**kwargs}) def on_retriever_start( @@ -486,7 +493,7 @@ def on_retriever_start( metadata: dict[str, Any] | None = None, name: str | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._start_span( parent_run_id, run_id, @@ -511,7 +518,7 @@ def on_retriever_end( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: self._end_span(run_id, output=documents, metadata={**kwargs}) def on_llm_new_token( @@ -522,7 +529,7 @@ def on_llm_new_token( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: if run_id not in self._first_token_times: now = time.perf_counter() self._first_token_times[run_id] = now @@ -537,7 +544,7 @@ def on_text( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: pass def on_retry( @@ -547,7 +554,7 @@ def on_retry( run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any, - ) -> Any: + ) -> None: pass def on_custom_event( @@ -559,7 +566,7 @@ def on_custom_event( tags: list[str] | None = None, metadata: dict[str, Any] | None = None, **kwargs: Any, - ) -> Any: + ) -> None: pass @@ -574,7 +581,7 @@ def clean_object(obj: dict[str, Any]) -> dict[str, Any]: def safe_parse_serialized_json(input_str: str) -> Any: try: return json.loads(input_str) - except: + except Exception: return input_str @@ -634,11 +641,21 @@ def _get_metrics_from_response(response: LLMResult): input_token_details = usage_metadata.get("input_token_details") if input_token_details and isinstance(input_token_details, dict): cache_read = input_token_details.get("cache_read") - cache_creation = input_token_details.get("cache_creation") + cache_creation = input_token_details.get("cache_creation") or 0 + + # langchain-anthropic may zero out cache_creation and use granular + # ephemeral keys (ephemeral_5m_input_tokens, ephemeral_1h_input_tokens) + # to avoid double-counting; sum them as the canonical cache_creation value. + if not cache_creation: + cache_creation = sum( + v + for k, v in input_token_details.items() + if k.startswith("ephemeral_") and isinstance(v, (int, float)) + ) if cache_read is not None: metrics["prompt_cached_tokens"] = cache_read - if cache_creation is not None: + if cache_creation: metrics["prompt_cache_creation_tokens"] = cache_creation if not metrics or not any(metrics.values()): diff --git a/py/src/braintrust/integrations/langchain/context.py b/py/src/braintrust/integrations/langchain/context.py index 5c6bb4e8..8e791fb5 100644 --- a/py/src/braintrust/integrations/langchain/context.py +++ b/py/src/braintrust/integrations/langchain/context.py @@ -1,8 +1,15 @@ from contextvars import ContextVar -from langchain_core.tracers.context import register_configure_hook -from braintrust_langchain.callbacks import BraintrustCallbackHandler +try: + from langchain_core.tracers.context import register_configure_hook +except ImportError: + raise ImportError( + "langchain-core is required to use set_global_handler. Install it with: pip install langchain-core" + ) + +from braintrust.integrations.langchain.callbacks import BraintrustCallbackHandler + __all__ = ["set_global_handler", "clear_global_handler"] diff --git a/py/src/braintrust/integrations/langchain/helpers.py b/py/src/braintrust/integrations/langchain/helpers.py index 7816dea4..1a067c4c 100644 --- a/py/src/braintrust/integrations/langchain/helpers.py +++ b/py/src/braintrust/integrations/langchain/helpers.py @@ -1,41 +1,16 @@ -from typing import Any, Dict, List, Sequence, Union, cast +from typing import Any, Dict, List from unittest.mock import ANY -from braintrust.logger import Span - -from .types import Span - -# Base types that can appear in values -PrimitiveValue = Union[str, int, float, bool, None, Span] -RecursiveValue = Union[PrimitiveValue, Dict[str, Any], Sequence[Any]] - - -def deep_hashable_dict(d: RecursiveValue): - """Recursively convert a dictionary into a hashable representation, handling nested values.""" - if isinstance(d, dict): - return frozenset((k, deep_hashable_dict(v)) for k, v in d.items()) - elif isinstance(d, Sequence) and not isinstance(d, str): - return frozenset(deep_hashable_dict(x) for x in d) - else: - return d - def assert_matches_object( - actual: RecursiveValue, - expected: RecursiveValue, + actual: Any, + expected: Any, ignore_order: bool = False, ) -> None: """Assert that actual contains all key-value pairs from expected. For lists, each item in expected must match the corresponding item in actual. For dicts, all key-value pairs in expected must exist in actual. - - Args: - actual: The actual value to check - expected: The expected value to match against - - Raises: - AssertionError: If the actual value doesn't match the expected value """ if isinstance(expected, (list, tuple)): assert isinstance(actual, (list, tuple)), f"Expected sequence but got {type(actual)}" @@ -52,35 +27,37 @@ def assert_matches_object( try: assert_matches_object(actual_item, expected_item) matched = True - except: + except Exception: pass assert matched, f"Expected {expected_item} in unordered sequence but couldn't find match in {actual}" elif isinstance(expected, dict): assert isinstance(actual, dict), f"Expected dict but got {type(actual)}" + actual_dict: Dict[str, Any] = actual for k, v in expected.items(): - assert k in actual, f"Missing key {k}" + assert k in actual_dict, f"Missing key {k}" if v is ANY: continue # ANY matches anything if isinstance(v, (dict, list, tuple)): - assert_matches_object(cast(RecursiveValue, actual[k]), cast(RecursiveValue, v)) + assert_matches_object(actual_dict[k], v) else: - assert actual[k] == v, f"Key {k}: expected {v} but got {actual[k]}" + assert actual_dict[k] == v, f"Key {k}: expected {v} but got {actual_dict[k]}" else: assert actual == expected, f"Expected {expected} but got {actual}" -def find_spans_by_attributes(spans: List[Span], **attributes: Any) -> List[Span]: +def find_spans_by_attributes(spans: List[Any], **attributes: Any) -> List[Any]: """Find all spans that match the given attributes.""" - matching_spans: List[Span] = [] + matching_spans: List[Any] = [] for span in spans: matches = True if "span_attributes" not in span: matches = False continue + span_attrs = span.get("span_attributes") or {} for key, value in attributes.items(): - if key not in span["span_attributes"] or span["span_attributes"][key] != value: + if key not in span_attrs or span_attrs.get(key) != value: matches = False break if matches: diff --git a/py/src/braintrust/integrations/langchain/test_anthropic.py b/py/src/braintrust/integrations/langchain/test_anthropic.py index d2e3364b..4c865fe7 100644 --- a/py/src/braintrust/integrations/langchain/test_anthropic.py +++ b/py/src/braintrust/integrations/langchain/test_anthropic.py @@ -1,27 +1,52 @@ +from pathlib import Path +from typing import Any from unittest.mock import ANY import pytest -from braintrust import flush +from braintrust import flush, logger +from braintrust.integrations.langchain import BraintrustCallbackHandler, set_global_handler +from braintrust.test_helpers import init_test_logger from langchain_anthropic import ChatAnthropic from langchain_core.prompts import ChatPromptTemplate -from braintrust_langchain import BraintrustCallbackHandler -from braintrust_langchain.context import set_global_handler -from tests.conftest import LoggerMemoryLogger -from tests.helpers import assert_matches_object +from .helpers import assert_matches_object + PROJECT_NAME = "langchain-anthropic" MODEL = "claude-sonnet-4-20250514" +@pytest.fixture(scope="module") +def vcr_config(): + return { + "cassette_library_dir": str(Path(__file__).parent / "cassettes"), + } + + +@pytest.fixture +def logger_memory_logger(): + test_logger = init_test_logger(PROJECT_NAME) + with logger._internal_with_memory_background_logger() as bgl: + yield (test_logger, bgl) + + +@pytest.fixture(autouse=True) +def clear_handler(): + from braintrust.integrations.langchain import clear_global_handler + + clear_global_handler() + yield + clear_global_handler() + + @pytest.mark.vcr def test_langchain_anthropic_integration( - logger_memory_logger: LoggerMemoryLogger, + logger_memory_logger, ): - logger, memory_logger = logger_memory_logger + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) set_global_handler(handler) prompt = ChatPromptTemplate.from_template("What is 1 + {number}?") @@ -63,27 +88,28 @@ def test_langchain_anthropic_integration( else: assert False, "No LLM span contained the expected answer '3'" + expected_metrics: dict[str, Any] = { + "completion_tokens": 13, + "end": ANY, + "prompt_tokens": 16, + "start": ANY, + "total_tokens": 29, + } assert_matches_object( llm_span["metrics"], - { - "completion_tokens": 13, - "end": ANY, - "prompt_tokens": 16, - "start": ANY, - "total_tokens": 29, - }, + expected_metrics, ) @pytest.mark.vcr @pytest.mark.asyncio async def test_async_langchain_invoke( - logger_memory_logger: LoggerMemoryLogger, + logger_memory_logger, ): - logger, memory_logger = logger_memory_logger + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) set_global_handler(handler) prompt = ChatPromptTemplate.from_template("What is 1 + {number}?") diff --git a/py/src/braintrust/integrations/langchain/test_callbacks.py b/py/src/braintrust/integrations/langchain/test_callbacks.py index 8cc9f926..9602513c 100644 --- a/py/src/braintrust/integrations/langchain/test_callbacks.py +++ b/py/src/braintrust/integrations/langchain/test_callbacks.py @@ -1,10 +1,13 @@ # pyright: reportTypedDictNotRequiredAccess=none import uuid +from pathlib import Path from typing import Dict, List, Union, cast import pytest +from braintrust import logger +from braintrust.integrations.langchain import BraintrustCallbackHandler from braintrust.logger import flush -from langchain_anthropic import ChatAnthropic +from braintrust.test_helpers import init_test_logger from langchain_core.callbacks import BaseCallbackHandler from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage from langchain_core.prompts import ChatPromptTemplate @@ -14,19 +17,41 @@ from langchain_openai import ChatOpenAI from pydantic import BaseModel, Field -from braintrust_langchain import BraintrustCallbackHandler - -from .conftest import LoggerMemoryLogger from .helpers import ANY, assert_matches_object, find_spans_by_attributes -from .types import Span + + +PROJECT_NAME = "langchain-py" + + +@pytest.fixture(scope="module") +def vcr_config(): + return { + "cassette_library_dir": str(Path(__file__).parent / "cassettes"), + } + + +@pytest.fixture +def logger_memory_logger(): + test_logger = init_test_logger(PROJECT_NAME) + with logger._internal_with_memory_background_logger() as bgl: + yield (test_logger, bgl) + + +@pytest.fixture(autouse=True) +def clear_handler(): + from braintrust.integrations.langchain import clear_global_handler + + clear_global_handler() + yield + clear_global_handler() @pytest.mark.vcr -def test_llm_calls(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_llm_calls(logger_memory_logger): + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) prompt = ChatPromptTemplate.from_template("What is 1 + {number}?") model = ChatOpenAI( model="gpt-4o-mini", @@ -58,12 +83,6 @@ def test_llm_calls(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": ANY, "response_metadata": ANY, "type": "ai", - "name": ANY, - "id": ANY, - "example": ANY, - "tool_calls": ANY, - "invalid_tool_calls": ANY, - "usage_metadata": ANY, }, "metadata": {"tags": []}, "span_id": root_span_id, @@ -79,8 +98,6 @@ def test_llm_calls(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": {}, "response_metadata": {}, "type": "human", - "name": None, - "id": None, } ] }, @@ -97,9 +114,6 @@ def test_llm_calls(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": {}, "response_metadata": {}, "type": "human", - "name": None, - "id": None, - "example": ANY, } ] ], @@ -115,21 +129,13 @@ def test_llm_calls(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": ANY, "response_metadata": ANY, "type": "ai", - "name": None, - "id": ANY, }, } ] ], "llm_output": { - "token_usage": { - "completion_tokens": ANY, - "prompt_tokens": ANY, - "total_tokens": ANY, - }, "model_name": "gpt-4o-mini-2024-07-18", }, - "run": None, "type": "LLMResult", }, "metrics": { @@ -151,11 +157,11 @@ def test_llm_calls(logger_memory_logger: LoggerMemoryLogger): @pytest.mark.vcr -def test_chain_with_memory(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_chain_with_memory(logger_memory_logger): + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) prompt = ChatPromptTemplate.from_template("{history} User: {input}") model = ChatOpenAI(model="gpt-4o-mini") chain: RunnableSerializable[Dict[str, str], BaseMessage] = prompt.pipe(model) @@ -200,8 +206,6 @@ def test_chain_with_memory(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": {}, "response_metadata": {}, "type": "human", - "name": None, - "id": None, } ] }, @@ -218,9 +222,6 @@ def test_chain_with_memory(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": {}, "response_metadata": {}, "type": "human", - "name": None, - "id": None, - "example": ANY, } ] ], @@ -236,21 +237,13 @@ def test_chain_with_memory(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": ANY, "response_metadata": ANY, "type": "ai", - "name": None, - "id": ANY, }, } ] ], "llm_output": { - "token_usage": { - "completion_tokens": ANY, - "prompt_tokens": ANY, - "total_tokens": ANY, - }, "model_name": "gpt-4o-mini-2024-07-18", }, - "run": None, "type": "LLMResult", }, "metrics": { @@ -272,11 +265,11 @@ def test_chain_with_memory(logger_memory_logger: LoggerMemoryLogger): @pytest.mark.vcr -def test_tool_usage(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_tool_usage(logger_memory_logger): + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) class CalculatorInput(BaseModel): operation: str = Field( @@ -331,9 +324,6 @@ def calculator(input: CalculatorInput) -> str: "additional_kwargs": {}, "response_metadata": {}, "type": "human", - "name": None, - "id": None, - "example": ANY, } ] ], @@ -362,25 +352,15 @@ def calculator(input: CalculatorInput) -> str: "message": { "content": ANY, # May be empty for tool calls "type": "ai", - "additional_kwargs": { - "tool_calls": ANY, # Tool call details - }, + "additional_kwargs": ANY, "response_metadata": ANY, - "name": None, - "id": ANY, }, } ] ], "llm_output": { - "token_usage": { - "completion_tokens": ANY, - "prompt_tokens": ANY, - "total_tokens": ANY, - }, "model_name": "gpt-4o-mini-2024-07-18", }, - "run": None, "type": "LLMResult", }, "metrics": { @@ -397,11 +377,11 @@ def calculator(input: CalculatorInput) -> str: @pytest.mark.vcr @pytest.mark.skip(reason="Not yet working with VCR.") -def test_parallel_execution(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_parallel_execution(logger_memory_logger): + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) model = ChatOpenAI( model="gpt-4o-mini", @@ -424,7 +404,7 @@ def test_parallel_execution(logger_memory_logger: LoggerMemoryLogger): map_chain.invoke({"topic": "bear"}, config={"callbacks": [cast(BaseCallbackHandler, handler)]}) - spans = cast(List[Span], memory_logger.pop()) + spans = cast(List, memory_logger.pop()) # Find the LLM spans llm_spans = find_spans_by_attributes(spans, name="ChatOpenAI") @@ -486,8 +466,8 @@ def test_parallel_execution(logger_memory_logger: LoggerMemoryLogger): @pytest.mark.vcr -def test_langgraph_state_management(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_langgraph_state_management(logger_memory_logger): + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() try: @@ -495,7 +475,7 @@ def test_langgraph_state_management(logger_memory_logger: LoggerMemoryLogger): except ImportError: pytest.skip("langgraph not installed") - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) model = ChatOpenAI( model="gpt-4o-mini", temperature=1, @@ -585,9 +565,6 @@ def say_bye(state: Dict[str, str]): "additional_kwargs": {}, "response_metadata": {}, "type": "human", - "name": None, - "id": None, - "example": ANY, } ] ], @@ -607,21 +584,13 @@ def say_bye(state: Dict[str, str]): "additional_kwargs": ANY, "response_metadata": ANY, "type": "ai", - "name": None, - "id": ANY, }, } ] ], "llm_output": { - "token_usage": { - "completion_tokens": ANY, - "prompt_tokens": ANY, - "total_tokens": ANY, - }, "model_name": "gpt-4o-mini-2024-07-18", }, - "run": None, "type": "LLMResult", }, "metrics": { @@ -651,11 +620,11 @@ def say_bye(state: Dict[str, str]): @pytest.mark.vcr -def test_chain_null_values(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_chain_null_values(logger_memory_logger): + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) run_id = uuid.UUID("f81d4fae-7dec-11d0-a765-00a0c91e6bf6") @@ -706,15 +675,15 @@ def test_chain_null_values(logger_memory_logger: LoggerMemoryLogger): ) -def test_consecutive_eval_calls(logger_memory_logger: LoggerMemoryLogger): +def test_consecutive_eval_calls(logger_memory_logger): from braintrust import Eval - logger, memory_logger = logger_memory_logger + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() def task_fn(input, hooks): # Create handler that will log LangChain spans - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) # Simulate LangChain chain execution by manually triggering callbacks run_id = uuid.uuid4() @@ -738,7 +707,7 @@ def task_fn(input, hooks): return output # Create a parent span to hold the eval - with logger.start_span(name="test-consecutive-eval", span_attributes={"type": "eval"}) as parent_span: + with test_logger.start_span(name="test-consecutive-eval", span_attributes={"type": "eval"}) as parent_span: # Run Eval with consecutive calls using parent parameter Eval( "test-consecutive-eval", @@ -861,19 +830,13 @@ def task_fn(input, hooks): ], ) - # Note: In this simplified test, we manually trigger LangChain callbacks but they don't - # create actual RunnableSequence spans in the logger. The key verification is that Eval() - # creates the proper hierarchy: root eval -> eval records -> tasks, and that consecutive - # calls work correctly with proper parent-child relationships. - # Real LangChain span integration is tested in other tests (test_llm_calls, etc.) - @pytest.mark.vcr -def test_streaming_ttft(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_streaming_ttft(logger_memory_logger): + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) prompt = ChatPromptTemplate.from_template("Count from 1 to 5.") model = ChatOpenAI( model="gpt-4o-mini", @@ -910,9 +873,6 @@ def test_streaming_ttft(logger_memory_logger: LoggerMemoryLogger): { "additional_kwargs": {}, "content": "Count from 1 to 5.", - "example": False, - "id": None, - "name": None, "response_metadata": {}, "type": "human", } @@ -953,11 +913,13 @@ def test_streaming_ttft(logger_memory_logger: LoggerMemoryLogger): @pytest.mark.vcr -def test_prompt_caching_tokens(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_prompt_caching_tokens(logger_memory_logger): + from langchain_anthropic import ChatAnthropic + + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger) + handler = BraintrustCallbackHandler(logger=test_logger) model = ChatAnthropic(model="claude-sonnet-4-5-20250929") diff --git a/py/src/braintrust/integrations/langchain/test_context.py b/py/src/braintrust/integrations/langchain/test_context.py index c0567396..52ce2c90 100644 --- a/py/src/braintrust/integrations/langchain/test_context.py +++ b/py/src/braintrust/integrations/langchain/test_context.py @@ -1,26 +1,53 @@ # pyright: reportTypedDictNotRequiredAccess=none +from pathlib import Path from typing import Dict from unittest.mock import ANY import pytest +from braintrust import logger +from braintrust.integrations.langchain import BraintrustCallbackHandler, set_global_handler +from braintrust.test_helpers import init_test_logger from langchain_core.callbacks import CallbackManager from langchain_core.messages import BaseMessage from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnableSerializable from langchain_openai import ChatOpenAI -from braintrust_langchain import BraintrustCallbackHandler, set_global_handler - -from .conftest import LoggerMemoryLogger from .helpers import assert_matches_object +PROJECT_NAME = "langchain-py" + + +@pytest.fixture(scope="module") +def vcr_config(): + return { + "cassette_library_dir": str(Path(__file__).parent / "cassettes"), + } + + +@pytest.fixture +def logger_memory_logger(): + test_logger = init_test_logger(PROJECT_NAME) + with logger._internal_with_memory_background_logger() as bgl: + yield (test_logger, bgl) + + +@pytest.fixture(autouse=True) +def clear_handler(): + from braintrust.integrations.langchain import clear_global_handler + + clear_global_handler() + yield + clear_global_handler() + + @pytest.mark.vcr -def test_global_handler(logger_memory_logger: LoggerMemoryLogger): - logger, memory_logger = logger_memory_logger +def test_global_handler(logger_memory_logger): + test_logger, memory_logger = logger_memory_logger assert not memory_logger.pop() - handler = BraintrustCallbackHandler(logger=logger, debug=True) + handler = BraintrustCallbackHandler(logger=test_logger, debug=True) set_global_handler(handler) # Make sure the handler is registered in the LangChain library @@ -61,12 +88,6 @@ def test_global_handler(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": ANY, "response_metadata": ANY, "type": "ai", - "name": ANY, - "id": ANY, - "example": ANY, - "tool_calls": ANY, - "invalid_tool_calls": ANY, - "usage_metadata": ANY, }, "metadata": {"tags": []}, "span_id": root_span_id, @@ -82,8 +103,6 @@ def test_global_handler(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": {}, "response_metadata": {}, "type": "human", - "name": None, - "id": None, } ] }, @@ -100,9 +119,6 @@ def test_global_handler(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": {}, "response_metadata": {}, "type": "human", - "name": None, - "id": None, - "example": ANY, } ] ], @@ -118,21 +134,13 @@ def test_global_handler(logger_memory_logger: LoggerMemoryLogger): "additional_kwargs": ANY, "response_metadata": ANY, "type": "ai", - "name": None, - "id": ANY, }, } ] ], "llm_output": { - "token_usage": { - "completion_tokens": ANY, - "prompt_tokens": ANY, - "total_tokens": ANY, - }, "model_name": "gpt-4o-mini-2024-07-18", }, - "run": None, "type": "LLMResult", }, "metrics": { diff --git a/py/src/braintrust/wrappers/langchain.py b/py/src/braintrust/wrappers/langchain.py index 6beeb578..10b5bfab 100644 --- a/py/src/braintrust/wrappers/langchain.py +++ b/py/src/braintrust/wrappers/langchain.py @@ -1,3 +1,7 @@ +# The LangChain integration has moved to braintrust.integrations.langchain. +# Update your imports to: from braintrust.integrations.langchain import BraintrustCallbackHandler, set_global_handler +# This module is kept for backward compatibility. + import contextvars import logging from typing import Any @@ -27,7 +31,10 @@ class BraintrustTracer(BaseCallbackHandler): def __init__(self, logger=None): - _logger.warning("BraintrustTracer is deprecated, use `pip install braintrust-langchain` instead") + _logger.warning( + "BraintrustTracer is deprecated, use BraintrustCallbackHandler instead. " + "Update your imports to: from braintrust.integrations.langchain import BraintrustCallbackHandler" + ) self.logger = logger self.spans = {} From 83c01fb55432c3b04108bd5c6b4299dfdd083778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 26 Mar 2026 23:15:00 +0000 Subject: [PATCH 3/8] chore: revert some change, squash this --- py/noxfile.py | 4 +-- .../integrations/langchain/callbacks.py | 14 ++-------- .../integrations/langchain/context.py | 9 +------ .../integrations/langchain/helpers.py | 27 ++++++++++++++----- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/py/noxfile.py b/py/noxfile.py index 0e372e7c..816c22f4 100644 --- a/py/noxfile.py +++ b/py/noxfile.py @@ -356,8 +356,8 @@ def pylint(session): session.install("google-adk") session.install("opentelemetry.instrumentation.openai") # langsmith is needed for the langsmith_wrapper module but not in VENDOR_PACKAGES - # langchain-core, langchain-openai, langchain-anthropic, and tenacity are needed for the langchain integration - session.install("langsmith", "langchain-core", "langchain-openai", "langchain-anthropic", "tenacity") + # langchain-core, langchain-openai, langchain-anthropic are needed for the langchain integration + session.install("langsmith", "langchain-core", "langchain-openai", "langchain-anthropic") result = session.run("git", "ls-files", "**/*.py", silent=True, log=False) files = [path for path in result.strip().splitlines() if path not in GENERATED_LINT_EXCLUDES] diff --git a/py/src/braintrust/integrations/langchain/callbacks.py b/py/src/braintrust/integrations/langchain/callbacks.py index 31014f24..3e9e7c2a 100644 --- a/py/src/braintrust/integrations/langchain/callbacks.py +++ b/py/src/braintrust/integrations/langchain/callbacks.py @@ -641,21 +641,11 @@ def _get_metrics_from_response(response: LLMResult): input_token_details = usage_metadata.get("input_token_details") if input_token_details and isinstance(input_token_details, dict): cache_read = input_token_details.get("cache_read") - cache_creation = input_token_details.get("cache_creation") or 0 - - # langchain-anthropic may zero out cache_creation and use granular - # ephemeral keys (ephemeral_5m_input_tokens, ephemeral_1h_input_tokens) - # to avoid double-counting; sum them as the canonical cache_creation value. - if not cache_creation: - cache_creation = sum( - v - for k, v in input_token_details.items() - if k.startswith("ephemeral_") and isinstance(v, (int, float)) - ) + cache_creation = input_token_details.get("cache_creation") if cache_read is not None: metrics["prompt_cached_tokens"] = cache_read - if cache_creation: + if cache_creation is not None: metrics["prompt_cache_creation_tokens"] = cache_creation if not metrics or not any(metrics.values()): diff --git a/py/src/braintrust/integrations/langchain/context.py b/py/src/braintrust/integrations/langchain/context.py index 8e791fb5..9de61670 100644 --- a/py/src/braintrust/integrations/langchain/context.py +++ b/py/src/braintrust/integrations/langchain/context.py @@ -1,14 +1,7 @@ from contextvars import ContextVar - -try: - from langchain_core.tracers.context import register_configure_hook -except ImportError: - raise ImportError( - "langchain-core is required to use set_global_handler. Install it with: pip install langchain-core" - ) - from braintrust.integrations.langchain.callbacks import BraintrustCallbackHandler +from langchain_core.tracers.context import register_configure_hook __all__ = ["set_global_handler", "clear_global_handler"] diff --git a/py/src/braintrust/integrations/langchain/helpers.py b/py/src/braintrust/integrations/langchain/helpers.py index 1a067c4c..0a2bc2ac 100644 --- a/py/src/braintrust/integrations/langchain/helpers.py +++ b/py/src/braintrust/integrations/langchain/helpers.py @@ -1,10 +1,25 @@ -from typing import Any, Dict, List +from typing import Any, Sequence from unittest.mock import ANY +# Base types that can appear in values +PrimitiveValue = str | int | float | bool | None +RecursiveValue = PrimitiveValue | dict[str, Any] | Sequence[Any] + + +def deep_hashable_dict(d: RecursiveValue): + """Recursively convert a dictionary into a hashable representation, handling nested values.""" + if isinstance(d, dict): + return frozenset((k, deep_hashable_dict(v)) for k, v in d.items()) + elif isinstance(d, Sequence) and not isinstance(d, str): + return frozenset(deep_hashable_dict(x) for x in d) + else: + return d + + def assert_matches_object( - actual: Any, - expected: Any, + actual: RecursiveValue, + expected: RecursiveValue, ignore_order: bool = False, ) -> None: """Assert that actual contains all key-value pairs from expected. @@ -34,7 +49,7 @@ def assert_matches_object( elif isinstance(expected, dict): assert isinstance(actual, dict), f"Expected dict but got {type(actual)}" - actual_dict: Dict[str, Any] = actual + actual_dict: dict[str, Any] = actual for k, v in expected.items(): assert k in actual_dict, f"Missing key {k}" if v is ANY: @@ -47,9 +62,9 @@ def assert_matches_object( assert actual == expected, f"Expected {expected} but got {actual}" -def find_spans_by_attributes(spans: List[Any], **attributes: Any) -> List[Any]: +def find_spans_by_attributes(spans: list[Any], **attributes: Any) -> list[Any]: """Find all spans that match the given attributes.""" - matching_spans: List[Any] = [] + matching_spans: list[Any] = [] for span in spans: matches = True if "span_attributes" not in span: From 53126088a0452f0fb7175e24077d836bd2ee2ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 26 Mar 2026 17:01:42 -0700 Subject: [PATCH 4/8] chore: fix ci and fix the slop --- integrations/langchain-py/pyproject.toml | 3 +++ .../integrations/langchain/callbacks.py | 19 ++++++------------- .../integrations/langchain/helpers.py | 7 +++++++ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/integrations/langchain-py/pyproject.toml b/integrations/langchain-py/pyproject.toml index 9bbf9d7a..e2b8bbe1 100644 --- a/integrations/langchain-py/pyproject.toml +++ b/integrations/langchain-py/pyproject.toml @@ -42,6 +42,9 @@ members = [ ".", ] +[tool.uv.sources] +braintrust = { path = "../../py", editable = true } + [dependency-groups] dev = [ "build", diff --git a/py/src/braintrust/integrations/langchain/callbacks.py b/py/src/braintrust/integrations/langchain/callbacks.py index 3e9e7c2a..993017ad 100644 --- a/py/src/braintrust/integrations/langchain/callbacks.py +++ b/py/src/braintrust/integrations/langchain/callbacks.py @@ -14,22 +14,15 @@ import braintrust from braintrust import NOOP_SPAN, Logger, Span, SpanAttributes, SpanTypeAttribute, current_span, init_logger from braintrust.version import VERSION as sdk_version +from langchain_core.agents import AgentAction, AgentFinish +from langchain_core.callbacks.base import BaseCallbackHandler +from langchain_core.documents import Document +from langchain_core.messages import BaseMessage +from langchain_core.outputs.llm_result import LLMResult +from tenacity import RetryCallState from typing_extensions import NotRequired -try: - from langchain_core.agents import AgentAction, AgentFinish - from langchain_core.callbacks.base import BaseCallbackHandler - from langchain_core.documents import Document - from langchain_core.messages import BaseMessage - from langchain_core.outputs.llm_result import LLMResult - from tenacity import RetryCallState -except ImportError: - raise ImportError( - "langchain-core and tenacity are required to use BraintrustCallbackHandler. " - "Install them with: pip install langchain-core tenacity" - ) - _logger = logging.getLogger("braintrust.wrappers.langchain") _INTEGRATION_NAME = "langchain-py" diff --git a/py/src/braintrust/integrations/langchain/helpers.py b/py/src/braintrust/integrations/langchain/helpers.py index 0a2bc2ac..f75b96db 100644 --- a/py/src/braintrust/integrations/langchain/helpers.py +++ b/py/src/braintrust/integrations/langchain/helpers.py @@ -26,6 +26,13 @@ def assert_matches_object( For lists, each item in expected must match the corresponding item in actual. For dicts, all key-value pairs in expected must exist in actual. + + Args: + actual: The actual value to check + expected: The expected value to match against + + Raises: + AssertionError: If the actual value doesn't match the expected value """ if isinstance(expected, (list, tuple)): assert isinstance(actual, (list, tuple)), f"Expected sequence but got {type(actual)}" From 6e91ae68f3bdc60b00cf1b07488e9851df449be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 26 Mar 2026 17:07:17 -0700 Subject: [PATCH 5/8] fix: context shim --- integrations/langchain-py/src/braintrust_langchain/context.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 integrations/langchain-py/src/braintrust_langchain/context.py diff --git a/integrations/langchain-py/src/braintrust_langchain/context.py b/integrations/langchain-py/src/braintrust_langchain/context.py new file mode 100644 index 00000000..da539664 --- /dev/null +++ b/integrations/langchain-py/src/braintrust_langchain/context.py @@ -0,0 +1,3 @@ +from braintrust.integrations.langchain.context import clear_global_handler, set_global_handler + +__all__ = ["set_global_handler", "clear_global_handler"] From 4240b354ce2f5b7d582eacbf38451505a1173ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 27 Mar 2026 01:20:03 +0000 Subject: [PATCH 6/8] cleanly migrate the cassettes --- .../integrations/langchain/callbacks.py | 8 +- ...nvoke => test_async_langchain_invoke.yaml} | 0 ...ith_memory => test_chain_with_memory.yaml} | 0 ...lobal_handler => test_global_handler.yaml} | 0 ...test_langchain_anthropic_integration.yaml} | 0 ...t => test_langgraph_state_management.yaml} | 0 .../{test_llm_calls => test_llm_calls.yaml} | 0 ...execution => test_parallel_execution.yaml} | 0 ...tokens => test_prompt_caching_tokens.yaml} | 0 ...treaming_ttft => test_streaming_ttft.yaml} | 78 +++++++++++++++++++ .../{test_tool_usage => test_tool_usage.yaml} | 0 .../integrations/langchain/conftest.py | 23 ++++++ .../integrations/langchain/test_callbacks.py | 8 -- .../integrations/langchain/test_context.py | 8 -- 14 files changed, 108 insertions(+), 17 deletions(-) rename py/src/braintrust/integrations/langchain/cassettes/{test_async_langchain_invoke => test_async_langchain_invoke.yaml} (100%) rename py/src/braintrust/integrations/langchain/cassettes/{test_chain_with_memory => test_chain_with_memory.yaml} (100%) rename py/src/braintrust/integrations/langchain/cassettes/{test_global_handler => test_global_handler.yaml} (100%) rename py/src/braintrust/integrations/langchain/cassettes/{test_langchain_anthropic_integration => test_langchain_anthropic_integration.yaml} (100%) rename py/src/braintrust/integrations/langchain/cassettes/{test_langgraph_state_management => test_langgraph_state_management.yaml} (100%) rename py/src/braintrust/integrations/langchain/cassettes/{test_llm_calls => test_llm_calls.yaml} (100%) rename py/src/braintrust/integrations/langchain/cassettes/{test_parallel_execution => test_parallel_execution.yaml} (100%) rename py/src/braintrust/integrations/langchain/cassettes/{test_prompt_caching_tokens => test_prompt_caching_tokens.yaml} (100%) rename py/src/braintrust/integrations/langchain/cassettes/{test_streaming_ttft => test_streaming_ttft.yaml} (72%) rename py/src/braintrust/integrations/langchain/cassettes/{test_tool_usage => test_tool_usage.yaml} (100%) create mode 100644 py/src/braintrust/integrations/langchain/conftest.py diff --git a/py/src/braintrust/integrations/langchain/callbacks.py b/py/src/braintrust/integrations/langchain/callbacks.py index 993017ad..e54bb365 100644 --- a/py/src/braintrust/integrations/langchain/callbacks.py +++ b/py/src/braintrust/integrations/langchain/callbacks.py @@ -634,7 +634,13 @@ def _get_metrics_from_response(response: LLMResult): input_token_details = usage_metadata.get("input_token_details") if input_token_details and isinstance(input_token_details, dict): cache_read = input_token_details.get("cache_read") - cache_creation = input_token_details.get("cache_creation") + # langchain-anthropic >= 1.4.0 maps cache_creation_input_tokens to + # ephemeral tier fields (ephemeral_5m_input_tokens, ephemeral_1h_input_tokens) + # rather than the top-level cache_creation field. Sum both for compat. + cache_creation = input_token_details.get("cache_creation") or ( + input_token_details.get("ephemeral_5m_input_tokens", 0) + + input_token_details.get("ephemeral_1h_input_tokens", 0) + ) if cache_read is not None: metrics["prompt_cached_tokens"] = cache_read diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_async_langchain_invoke b/py/src/braintrust/integrations/langchain/cassettes/test_async_langchain_invoke.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_async_langchain_invoke rename to py/src/braintrust/integrations/langchain/cassettes/test_async_langchain_invoke.yaml diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_chain_with_memory b/py/src/braintrust/integrations/langchain/cassettes/test_chain_with_memory.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_chain_with_memory rename to py/src/braintrust/integrations/langchain/cassettes/test_chain_with_memory.yaml diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_global_handler b/py/src/braintrust/integrations/langchain/cassettes/test_global_handler.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_global_handler rename to py/src/braintrust/integrations/langchain/cassettes/test_global_handler.yaml diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_langchain_anthropic_integration b/py/src/braintrust/integrations/langchain/cassettes/test_langchain_anthropic_integration.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_langchain_anthropic_integration rename to py/src/braintrust/integrations/langchain/cassettes/test_langchain_anthropic_integration.yaml diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_langgraph_state_management b/py/src/braintrust/integrations/langchain/cassettes/test_langgraph_state_management.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_langgraph_state_management rename to py/src/braintrust/integrations/langchain/cassettes/test_langgraph_state_management.yaml diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_llm_calls b/py/src/braintrust/integrations/langchain/cassettes/test_llm_calls.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_llm_calls rename to py/src/braintrust/integrations/langchain/cassettes/test_llm_calls.yaml diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_parallel_execution b/py/src/braintrust/integrations/langchain/cassettes/test_parallel_execution.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_parallel_execution rename to py/src/braintrust/integrations/langchain/cassettes/test_parallel_execution.yaml diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_prompt_caching_tokens b/py/src/braintrust/integrations/langchain/cassettes/test_prompt_caching_tokens.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_prompt_caching_tokens rename to py/src/braintrust/integrations/langchain/cassettes/test_prompt_caching_tokens.yaml diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_streaming_ttft b/py/src/braintrust/integrations/langchain/cassettes/test_streaming_ttft.yaml similarity index 72% rename from py/src/braintrust/integrations/langchain/cassettes/test_streaming_ttft rename to py/src/braintrust/integrations/langchain/cassettes/test_streaming_ttft.yaml index 1ee7a837..6315e870 100644 --- a/py/src/braintrust/integrations/langchain/cassettes/test_streaming_ttft +++ b/py/src/braintrust/integrations/langchain/cassettes/test_streaming_ttft.yaml @@ -295,4 +295,82 @@ interactions: status: code: 200 message: OK +- request: + body: '{"messages":[{"content":"Count from 1 to 5.","role":"user"}],"model":"gpt-4o-mini","max_completion_tokens":50,"stream":true,"stream_options":{"include_usage":true}}' + headers: + Accept: + - application/json + Connection: + - keep-alive + Content-Type: + - application/json + Host: + - api.openai.com + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"obfuscation":"uoycSw"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":"1"},"logprobs":null,"finish_reason":null}],"obfuscation":"7R9sCOG"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"obfuscation":"jNZOnCU"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":" + "},"logprobs":null,"finish_reason":null}],"obfuscation":"NTkR0fq"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":"2"},"logprobs":null,"finish_reason":null}],"obfuscation":"KhfgFBA"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"obfuscation":"u5zk4uv"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":" + "},"logprobs":null,"finish_reason":null}],"obfuscation":"yQyBcA4"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":"3"},"logprobs":null,"finish_reason":null}],"obfuscation":"HhGcZch"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"obfuscation":"GNLE7Ci"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":" + "},"logprobs":null,"finish_reason":null}],"obfuscation":"d0EKjlZ"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":"4"},"logprobs":null,"finish_reason":null}],"obfuscation":"YytmIuX"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"obfuscation":"Umbehc1"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":" + "},"logprobs":null,"finish_reason":null}],"obfuscation":"3xi8C7o"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":"5"},"logprobs":null,"finish_reason":null}],"obfuscation":"N0uOsTp"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"obfuscation":"RilMN7a"} + + + data: {"id":"chatcmpl-CZR0zJXGi0lsnYkPoiga2R6HChxps","object":"chat.completion.chunk","created":1762561361,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_560af6e559","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"obfuscation":"oF"} + + + data: [DONE] + + + ' + headers: + Content-Type: + - text/event-stream; charset=utf-8 + status: + code: 200 + message: OK + url: https://api.openai.com/v1/chat/completions version: 1 diff --git a/py/src/braintrust/integrations/langchain/cassettes/test_tool_usage b/py/src/braintrust/integrations/langchain/cassettes/test_tool_usage.yaml similarity index 100% rename from py/src/braintrust/integrations/langchain/cassettes/test_tool_usage rename to py/src/braintrust/integrations/langchain/cassettes/test_tool_usage.yaml diff --git a/py/src/braintrust/integrations/langchain/conftest.py b/py/src/braintrust/integrations/langchain/conftest.py new file mode 100644 index 00000000..325c1c62 --- /dev/null +++ b/py/src/braintrust/integrations/langchain/conftest.py @@ -0,0 +1,23 @@ +import os +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="module") +def vcr_config(): + record_mode = "none" if (os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS")) else "once" + + return { + "cassette_library_dir": str(Path(__file__).parent / "cassettes"), + "filter_headers": [ + "authorization", + "x-goog-api-key", + "x-api-key", + "api-key", + "openai-api-key", + ], + "record_mode": record_mode, + "match_on": ["uri", "method"], + "decode_compressed_response": True, + } diff --git a/py/src/braintrust/integrations/langchain/test_callbacks.py b/py/src/braintrust/integrations/langchain/test_callbacks.py index 9602513c..94970e6c 100644 --- a/py/src/braintrust/integrations/langchain/test_callbacks.py +++ b/py/src/braintrust/integrations/langchain/test_callbacks.py @@ -1,6 +1,5 @@ # pyright: reportTypedDictNotRequiredAccess=none import uuid -from pathlib import Path from typing import Dict, List, Union, cast import pytest @@ -23,13 +22,6 @@ PROJECT_NAME = "langchain-py" -@pytest.fixture(scope="module") -def vcr_config(): - return { - "cassette_library_dir": str(Path(__file__).parent / "cassettes"), - } - - @pytest.fixture def logger_memory_logger(): test_logger = init_test_logger(PROJECT_NAME) diff --git a/py/src/braintrust/integrations/langchain/test_context.py b/py/src/braintrust/integrations/langchain/test_context.py index 52ce2c90..5e41e5ed 100644 --- a/py/src/braintrust/integrations/langchain/test_context.py +++ b/py/src/braintrust/integrations/langchain/test_context.py @@ -1,5 +1,4 @@ # pyright: reportTypedDictNotRequiredAccess=none -from pathlib import Path from typing import Dict from unittest.mock import ANY @@ -19,13 +18,6 @@ PROJECT_NAME = "langchain-py" -@pytest.fixture(scope="module") -def vcr_config(): - return { - "cassette_library_dir": str(Path(__file__).parent / "cassettes"), - } - - @pytest.fixture def logger_memory_logger(): test_logger = init_test_logger(PROJECT_NAME) From 37039cec48bd157e8fe0e422f35107074deb31ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 27 Mar 2026 02:09:29 +0000 Subject: [PATCH 7/8] compatibility check --- .../langchain-py/src/tests/test_compat.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 integrations/langchain-py/src/tests/test_compat.py diff --git a/integrations/langchain-py/src/tests/test_compat.py b/integrations/langchain-py/src/tests/test_compat.py new file mode 100644 index 00000000..1a17712a --- /dev/null +++ b/integrations/langchain-py/src/tests/test_compat.py @@ -0,0 +1,43 @@ +"""Test that braintrust_langchain re-exports the public API from braintrust.integrations.langchain.""" + +import importlib +import warnings + +import pytest + + +def test_public_api_reexported(): + """All public API symbols should be importable from braintrust_langchain.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + from braintrust_langchain import ( + BraintrustCallbackHandler, + BraintrustTracer, + clear_global_handler, + set_global_handler, + ) + + assert callable(BraintrustCallbackHandler) + assert callable(BraintrustTracer) + assert callable(set_global_handler) + assert callable(clear_global_handler) + + +def test_context_module_reexported(): + """braintrust_langchain.context should still work for users who imported from there directly.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + from braintrust_langchain.context import clear_global_handler, set_global_handler + + assert callable(set_global_handler) + assert callable(clear_global_handler) + + +def test_deprecation_warning(): + """Importing braintrust_langchain should emit a DeprecationWarning.""" + import braintrust_langchain + + with pytest.warns(DeprecationWarning, match="braintrust-langchain is deprecated"): + importlib.reload(braintrust_langchain) From b5786923c94a783e10596222e2b07884b59fc82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 27 Mar 2026 17:25:37 -0700 Subject: [PATCH 8/8] chore: fix pylint linting issue --- py/src/braintrust/contrib/temporal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py/src/braintrust/contrib/temporal/__init__.py b/py/src/braintrust/contrib/temporal/__init__.py index 61e4397e..6de236be 100644 --- a/py/src/braintrust/contrib/temporal/__init__.py +++ b/py/src/braintrust/contrib/temporal/__init__.py @@ -430,7 +430,7 @@ def __init__(self, logger: Any | None = None) -> None: workflow_runner=_modify_workflow_runner, ) else: - super().__init__( + super().__init__( # pylint: disable=unexpected-keyword-arg name="braintrust", client_interceptors=[interceptor], worker_interceptors=[interceptor],