Compare commits

...

10 Commits

Author SHA1 Message Date
Kevin Frank
65309fc57f fix(anthropic): tag multiple system message blocks for better cache hits
When the system message has multiple content blocks (e.g. a static base
prompt followed by dynamic memory content from middleware), tag both the
second-to-last and last blocks with cache_control instead of only the
last block. This creates two breakpoints so that when the last block
changes (e.g. memory differs across conversations), earlier blocks still
get cache hits.

Single-block system messages are unchanged (tags that one block).

Also fixes class and function docstrings to accurately describe behavior
(tags last tool, not all tools).
2026-04-10 16:14:22 -05:00
Eugene Yurtsev
9f232caa7a chore(infra): limit codspeed to benchmark tests (#36658)
limit codspeed to benchmark tests folder only
2026-04-10 12:02:49 -04:00
Eugene Yurtsev
9ee4617fba release(core): 1.3.0a1 (#36656)
1.3.0a1 release
2026-04-10 11:58:34 -04:00
Eugene Yurtsev
2c9296c423 perf(langchain): add lru cache when resolving types (#36649)
speed up for re-instantiation of the same agent
2026-04-10 11:25:58 -04:00
Eugene Yurtsev
1ca47a5411 perf(langchain): add another init test with middleware (#36644)
add another init test with middleware
2026-04-10 11:20:23 -04:00
Eugene Yurtsev
42d677e41f chore(infra): swap runner for codspeed (#36653)
change runner for codspeed
2026-04-10 11:06:03 -04:00
Eugene Yurtsev
1df6816fbd chore(infra): add langchain to codspeed (#36650)
add langchain to codspeed
2026-04-10 10:48:42 -04:00
Eugene Yurtsev
af4d711a2f chore(core): reduce streaming metadata / perf (#36588)
- looking into reducing streaming metadata / perfm

---------

Co-authored-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2026-04-10 10:47:54 -04:00
Eugene Yurtsev
f0c5a28fa0 perf(langchain): add benchmark command (#36641)
add benchmark in Makefile
2026-04-09 16:05:17 -04:00
langchain-model-profile-bot[bot]
644821e5b4 chore(model-profiles): refresh model profile data (#36630)
Automated refresh of model profile data for all in-monorepo partner
integrations via `langchain-profiles refresh`.

🤖 Generated by the `refresh_model_profiles` workflow.

Co-authored-by: mdrxy <61371264+mdrxy@users.noreply.github.com>
2026-04-09 11:15:42 -04:00
24 changed files with 1366 additions and 48 deletions

View File

@@ -282,6 +282,8 @@ if __name__ == "__main__":
if file.startswith("libs/core"):
dirs_to_run["codspeed"].add("libs/core")
if file.startswith("libs/langchain_v1"):
dirs_to_run["codspeed"].add("libs/langchain_v1")
if any(file.startswith(dir_) for dir_ in LANGCHAIN_DIRS):
# add that dir and all dirs after in LANGCHAIN_DIRS
# for extended testing

View File

@@ -52,7 +52,7 @@ jobs:
name: "⚡ CodSpeed Benchmarks"
needs: [build]
if: ${{ needs.build.outputs.codspeed != '[]' }}
runs-on: ubuntu-latest
runs-on: codspeed-macro
strategy:
matrix:
job-configs: ${{ fromJson(needs.build.outputs.codspeed) }}
@@ -77,9 +77,7 @@ jobs:
token: ${{ secrets.CODSPEED_TOKEN }}
run: |
cd ${{ matrix.job-configs.working-directory }}
if [ "${{ matrix.job-configs.working-directory }}" = "libs/core" ]; then
if [ "${{ matrix.job-configs.working-directory }}" = "libs/core" ] || [ "${{ matrix.job-configs.working-directory }}" = "libs/langchain_v1" ]; then
uv run --no-sync pytest ./tests/benchmarks --codspeed
else
uv run --no-sync pytest ./tests/unit_tests/ -m benchmark --codspeed
fi
mode: ${{ matrix.job-configs.codspeed-mode }}

View File

@@ -17,7 +17,7 @@ test tests:
-u LANGSMITH_API_KEY \
-u LANGSMITH_TRACING \
-u LANGCHAIN_PROJECT \
uv run --group test pytest -n auto $(PYTEST_EXTRA) --disable-socket --allow-unix-socket $(TEST_FILE)
uv run --group test pytest -n auto --benchmark-disable $(PYTEST_EXTRA) --disable-socket --allow-unix-socket $(TEST_FILE)
test_watch:
env \

View File

@@ -7,7 +7,7 @@ import atexit
import functools
import logging
from abc import ABC, abstractmethod
from collections.abc import Callable
from collections.abc import Callable, Mapping
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager, contextmanager
from contextvars import copy_context
@@ -1614,6 +1614,9 @@ class CallbackManager(BaseCallbackManager):
local_tags: list[str] | None = None,
inheritable_metadata: dict[str, Any] | None = None,
local_metadata: dict[str, Any] | None = None,
*,
langsmith_inheritable_metadata: Mapping[str, Any] | None = None,
langsmith_inheritable_tags: list[str] | None = None,
) -> CallbackManager:
"""Configure the callback manager.
@@ -1625,6 +1628,10 @@ class CallbackManager(BaseCallbackManager):
local_tags: The local tags.
inheritable_metadata: The inheritable metadata.
local_metadata: The local metadata.
langsmith_inheritable_metadata: Default inheritable metadata applied
to any `LangChainTracer` handlers via `set_defaults`.
langsmith_inheritable_tags: Default inheritable tags applied to any
`LangChainTracer` handlers via `set_defaults`.
Returns:
The configured callback manager.
@@ -1638,6 +1645,8 @@ class CallbackManager(BaseCallbackManager):
inheritable_metadata,
local_metadata,
verbose=verbose,
langsmith_inheritable_metadata=langsmith_inheritable_metadata,
langsmith_inheritable_tags=langsmith_inheritable_tags,
)
@@ -2134,6 +2143,9 @@ class AsyncCallbackManager(BaseCallbackManager):
local_tags: list[str] | None = None,
inheritable_metadata: dict[str, Any] | None = None,
local_metadata: dict[str, Any] | None = None,
*,
langsmith_inheritable_metadata: Mapping[str, Any] | None = None,
langsmith_inheritable_tags: list[str] | None = None,
) -> AsyncCallbackManager:
"""Configure the async callback manager.
@@ -2145,6 +2157,10 @@ class AsyncCallbackManager(BaseCallbackManager):
local_tags: The local tags.
inheritable_metadata: The inheritable metadata.
local_metadata: The local metadata.
langsmith_inheritable_metadata: Default inheritable metadata applied
to any `LangChainTracer` handlers via `set_defaults`.
langsmith_inheritable_tags: Default inheritable tags applied to any
`LangChainTracer` handlers via `set_defaults`.
Returns:
The configured async callback manager.
@@ -2158,6 +2174,8 @@ class AsyncCallbackManager(BaseCallbackManager):
inheritable_metadata,
local_metadata,
verbose=verbose,
langsmith_inheritable_metadata=langsmith_inheritable_metadata,
langsmith_inheritable_tags=langsmith_inheritable_tags,
)
@@ -2304,6 +2322,8 @@ def _configure(
local_metadata: dict[str, Any] | None = None,
*,
verbose: bool = False,
langsmith_inheritable_metadata: Mapping[str, Any] | None = None,
langsmith_inheritable_tags: list[str] | None = None,
) -> T:
"""Configure the callback manager.
@@ -2316,6 +2336,10 @@ def _configure(
inheritable_metadata: The inheritable metadata.
local_metadata: The local metadata.
verbose: Whether to enable verbose mode.
langsmith_inheritable_metadata: Default inheritable metadata applied to
any `LangChainTracer` handlers via `set_defaults`.
langsmith_inheritable_tags: Default inheritable tags applied to any
`LangChainTracer` handlers via `set_defaults`.
Raises:
RuntimeError: If `LANGCHAIN_TRACING` is set but `LANGCHAIN_TRACING_V2` is not.
@@ -2387,8 +2411,6 @@ def _configure(
if inheritable_metadata or local_metadata:
callback_manager.add_metadata(inheritable_metadata or {})
callback_manager.add_metadata(local_metadata or {}, inherit=False)
if tracing_metadata:
callback_manager.add_metadata(tracing_metadata.copy())
if tracing_tags:
callback_manager.add_tags(tracing_tags.copy())
@@ -2440,6 +2462,7 @@ def _configure(
else tracing_context["client"]
),
tags=tracing_tags,
metadata=tracing_metadata,
)
callback_manager.add_handler(handler)
except Exception as e:
@@ -2479,6 +2502,32 @@ def _configure(
for handler in callback_manager.handlers
):
callback_manager.add_handler(var_handler, inheritable)
if tracing_metadata:
langsmith_inheritable_metadata = {
**tracing_metadata,
**(langsmith_inheritable_metadata or {}),
}
if langsmith_inheritable_metadata or langsmith_inheritable_tags:
callback_manager.handlers = [
handler.copy_with_metadata_defaults(
metadata=langsmith_inheritable_metadata,
tags=langsmith_inheritable_tags,
)
if isinstance(handler, LangChainTracer)
else handler
for handler in callback_manager.handlers
]
callback_manager.inheritable_handlers = [
handler.copy_with_metadata_defaults(
metadata=langsmith_inheritable_metadata,
tags=langsmith_inheritable_tags,
)
if isinstance(handler, LangChainTracer)
else handler
for handler in callback_manager.inheritable_handlers
]
return callback_manager

View File

@@ -138,6 +138,28 @@ COPIABLE_KEYS = [
"configurable",
]
# Users are expected to use the `context` API with a context object
# (which does not get traced)
CONFIGURABLE_TO_TRACING_METADATA_EXCLUDED_KEYS = frozenset(("api_key",))
def _get_langsmith_inheritable_metadata_from_config(
config: RunnableConfig,
) -> dict[str, Any] | None:
"""Get LangSmith-only inheritable metadata defaults derived from config."""
configurable = config.get("configurable") or {}
metadata = {
key: value
for key, value in configurable.items()
if not key.startswith("__")
and isinstance(value, (str, int, float, bool))
and key not in config.get("metadata", {})
and key not in CONFIGURABLE_TO_TRACING_METADATA_EXCLUDED_KEYS
}
return metadata or None
DEFAULT_RECURSION_LIMIT = 25
@@ -264,14 +286,11 @@ def ensure_config(config: RunnableConfig | None = None) -> RunnableConfig:
for k, v in config.items():
if k not in CONFIG_KEYS and v is not None:
empty["configurable"][k] = v
for key, value in empty.get("configurable", {}).items():
if (
not key.startswith("__")
and isinstance(value, (str, int, float, bool))
and key not in empty["metadata"]
and key != "api_key"
):
empty["metadata"][key] = value
if (
isinstance(model := empty.get("configurable", {}).get("model"), str)
and "model" not in empty["metadata"]
):
empty["metadata"]["model"] = model
return empty
@@ -508,6 +527,9 @@ def get_callback_manager_for_config(config: RunnableConfig) -> CallbackManager:
inheritable_callbacks=config.get("callbacks"),
inheritable_tags=config.get("tags"),
inheritable_metadata=config.get("metadata"),
langsmith_inheritable_metadata=_get_langsmith_inheritable_metadata_from_config(
config
),
)
@@ -526,6 +548,9 @@ def get_async_callback_manager_for_config(
inheritable_callbacks=config.get("callbacks"),
inheritable_tags=config.get("tags"),
inheritable_metadata=config.get("metadata"),
langsmith_inheritable_metadata=_get_langsmith_inheritable_metadata_from_config(
config
),
)

View File

@@ -51,6 +51,8 @@ class _TracerCore(ABC):
_schema_format: Literal[
"original", "streaming_events", "original+chat"
] = "original",
run_map: dict[str, Run] | None = None,
order_map: dict[UUID, tuple[UUID, str]] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the tracer.
@@ -70,6 +72,8 @@ class _TracerCore(ABC):
streaming events.
- `'original+chat'` is a format that is the same as `'original'` except
it does NOT raise an attribute error `on_chat_model_start`
run_map: Optional shared map of run ID to run.
order_map: Optional shared map of run ID to trace ordering data.
**kwargs: Additional keyword arguments that will be passed to the
superclass.
"""
@@ -77,10 +81,10 @@ class _TracerCore(ABC):
self._schema_format = _schema_format # For internal use only API will change.
self.run_map: dict[str, Run] = {}
self.run_map = run_map if run_map is not None else {}
"""Map of run ID to run. Cleared on run end."""
self.order_map: dict[UUID, tuple[UUID, str]] = {}
self.order_map = order_map if order_map is not None else {}
"""Map of run ID to (trace_id, dotted_order). Cleared when tracer GCed."""
@abstractmethod

View File

@@ -27,6 +27,8 @@ from langchain_core.tracers.base import BaseTracer
from langchain_core.tracers.schemas import Run
if TYPE_CHECKING:
from collections.abc import Mapping
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatGenerationChunk, GenerationChunk
@@ -124,6 +126,8 @@ class LangChainTracer(BaseTracer):
project_name: str | None = None,
client: Client | None = None,
tags: list[str] | None = None,
*,
metadata: Mapping[str, str] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the LangChain tracer.
@@ -139,6 +143,9 @@ class LangChainTracer(BaseTracer):
tags: The tags.
Defaults to an empty list.
metadata: Additional metadata to include if it isn't already in the run.
Defaults to None.
**kwargs: Additional keyword arguments.
"""
super().__init__(**kwargs)
@@ -150,6 +157,39 @@ class LangChainTracer(BaseTracer):
self.tags = tags or []
self.latest_run: Run | None = None
self.run_has_token_event_map: dict[str, bool] = {}
self.tracing_metadata: dict[str, str] | None = (
dict(metadata) if metadata is not None else None
)
def copy_with_metadata_defaults(
self,
*,
metadata: Mapping[str, str] | None = None,
tags: list[str] | None = None,
) -> LangChainTracer:
"""Return a new tracer with merged tracer-only defaults."""
base_metadata = self.tracing_metadata
if metadata is None:
merged_metadata = dict(base_metadata) if base_metadata is not None else None
elif base_metadata is None:
merged_metadata = dict(metadata)
else:
merged_metadata = dict(base_metadata)
for key, value in metadata.items():
if key not in merged_metadata:
merged_metadata[key] = value
merged_tags = sorted(set(self.tags + tags)) if tags else self.tags
return self.__class__(
example_id=self.example_id,
project_name=self.project_name,
client=self.client,
tags=merged_tags,
metadata=merged_metadata,
run_map=self.run_map,
order_map=self.order_map,
)
def _start_trace(self, run: Run) -> None:
if self.project_name:
@@ -263,6 +303,7 @@ class LangChainTracer(BaseTracer):
try:
run.extra["runtime"] = get_runtime_environment()
run.tags = self._get_tags(run)
_patch_missing_metadata(self, run)
if run.ls_client is not self.client:
run.ls_client = self.client
run.post()
@@ -398,3 +439,17 @@ class LangChainTracer(BaseTracer):
"""Wait for the given futures to complete."""
if self.client is not None:
self.client.flush()
def _patch_missing_metadata(self: LangChainTracer, run: Run) -> None:
if not self.tracing_metadata:
return
metadata = run.metadata
patched = None
for k, v in self.tracing_metadata.items():
if k not in metadata:
if patched is None:
# Copy on first miss to avoid mutating the shared dict.
patched = {**metadata}
run.extra["metadata"] = patched
patched[k] = v

View File

@@ -1,3 +1,3 @@
"""langchain-core version information and utilities."""
VERSION = "1.2.28"
VERSION = "1.3.0a1"

View File

@@ -21,7 +21,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
version = "1.2.28"
version = "1.3.0a1"
requires-python = ">=3.10.0,<4.0.0"
dependencies = [
"langsmith>=0.3.45,<1.0.0",

View File

@@ -16,6 +16,7 @@ from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHan
from langchain_core.runnables import RunnableBinding, RunnablePassthrough
from langchain_core.runnables.config import (
RunnableConfig,
_get_langsmith_inheritable_metadata_from_config,
_set_config_context,
ensure_config,
merge_configs,
@@ -61,7 +62,7 @@ def test_ensure_config() -> None:
assert config["configurable"] is not arg["configurable"]
assert config == {
"tags": ["tag1", "tag2"],
"metadata": {"foo": "bar", "baz": "qux", "something": "else"},
"metadata": {"foo": "bar"},
"callbacks": [arg["callbacks"][0]],
"recursion_limit": 100,
"configurable": {"baz": "qux", "something": "else"},
@@ -71,6 +72,145 @@ def test_ensure_config() -> None:
}
def test_ensure_config_copies_model_to_metadata() -> None:
config = ensure_config(
{
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
"some_api_key": "opaque-token",
"custom_setting": {"nested": True},
"none_value": None,
},
"metadata": {"nooverride": 18},
}
)
assert config["metadata"] == {"nooverride": 18, "model": "gpt-4o"}
assert config["configurable"] == {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
"some_api_key": "opaque-token",
"custom_setting": {"nested": True},
"none_value": None,
}
def test_ensure_config_metadata_is_not_overridden_by_configurable_model() -> None:
config = ensure_config(
{
"configurable": {
"model": "from-configurable",
"run_id": None,
"checkpoint_ns": "from-configurable",
},
"metadata": {
"model": "from-metadata",
"run_id": "from-metadata",
"checkpoint_ns": "from-metadata",
},
}
)
assert config["metadata"] == {
"model": "from-metadata",
"run_id": "from-metadata",
"checkpoint_ns": "from-metadata",
}
assert config["configurable"] == {
"model": "from-configurable",
"run_id": None,
"checkpoint_ns": "from-configurable",
}
def test_ensure_config_copies_top_level_model_to_metadata() -> None:
config = ensure_config(
cast(
"RunnableConfig",
{
"model": "gpt-4o",
"metadata": {"nooverride": 18},
},
)
)
assert config["metadata"] == {"nooverride": 18, "model": "gpt-4o"}
assert config["configurable"] == {"model": "gpt-4o"}
def test_get_langsmith_inheritable_metadata_from_config_uses_previous_copy_rules() -> (
None
):
config = ensure_config(
cast(
"RunnableConfig",
{
"something": "else",
"metadata": {
"foo": "bar",
"model": "from-metadata",
"checkpoint_ns": "from-metadata",
},
"configurable": {
"baz": "qux",
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "from-configurable",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "from-configurable",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
"api_key": "should-not-propagate",
"__secret_key": "should-not-propagate",
"temperature": 0.5,
"streaming": True,
"custom_setting": {"nested": True},
"none_value": None,
},
},
)
)
assert _get_langsmith_inheritable_metadata_from_config(config) == {
"something": "else",
"baz": "qux",
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
"temperature": 0.5,
"streaming": True,
}
async def test_merge_config_callbacks() -> None:
manager: RunnableConfig = {
"callbacks": CallbackManager(handlers=[StdOutCallbackHandler()])

View File

@@ -1162,7 +1162,7 @@ async def test_with_config_metadata_passthrough(mocker: MockerFixture) -> None:
"callbacks": None,
"recursion_limit": 25,
"configurable": {"hello": "there", "__secret_key": "nahnah"},
"metadata": {"hello": "there", "bye": "now"},
"metadata": {"bye": "now"},
},
)
spy.reset_mock()

View File

@@ -1,7 +1,10 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import sys
import threading
import uuid
from inspect import isasyncgenfunction
from typing import TYPE_CHECKING, Any, Literal
@@ -12,13 +15,15 @@ from langsmith import Client, RunTree, get_current_run_tree, traceable
from langsmith.run_helpers import tracing_context
from langsmith.utils import get_env_var
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain_core.callbacks.manager import CallbackManager
from langchain_core.runnables.base import RunnableLambda, RunnableParallel
from langchain_core.tracers.langchain import LangChainTracer
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable, Coroutine, Generator
from collections.abc import AsyncGenerator, Callable, Coroutine, Generator, Mapping
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.runnables.config import RunnableConfig
def _get_posts(client: Client) -> list[dict[str, Any]]:
@@ -43,12 +48,15 @@ def _get_posts(client: Client) -> list[dict[str, Any]]:
def _create_tracer_with_mocked_client(
project_name: str | None = None,
tags: list[str] | None = None,
metadata: Mapping[str, str] | None = None,
) -> LangChainTracer:
mock_session = MagicMock()
mock_client_ = Client(
session=mock_session, api_key="test", auto_batch_tracing=False
)
return LangChainTracer(client=mock_client_, project_name=project_name, tags=tags)
return LangChainTracer(
client=mock_client_, project_name=project_name, tags=tags, metadata=metadata
)
def test_tracing_context() -> None:
@@ -75,6 +83,38 @@ def test_tracing_context() -> None:
assert all(post["session_name"] == project_name for post in posts)
def test_inheritable_metadata_respects_explicit_metadata_with_tracing_context() -> None:
"""Tracer defaults fill missing keys while run metadata keeps precedence."""
tracer = _create_tracer_with_mocked_client()
@RunnableLambda
def my_func(x: int) -> int:
return x
callbacks = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={
"tenant": "from_tracer",
"shared": "from_tracer",
},
)
with tracing_context(enabled=True, client=tracer.client):
my_func.invoke(
1,
{
"callbacks": callbacks,
"metadata": {"shared": "from_run", "explicit": "from_run"},
},
)
posts = _get_posts(tracer.client)
assert len(posts) == 1
metadata = posts[0].get("extra", {}).get("metadata", {})
assert metadata["tenant"] == "from_tracer"
assert metadata["shared"] == "from_run"
assert metadata["explicit"] == "from_run"
def test_config_traceable_handoff() -> None:
if hasattr(get_env_var, "cache_clear"):
get_env_var.cache_clear() # type: ignore[attr-defined]
@@ -466,7 +506,10 @@ def test_tree_is_constructed(parent_type: Literal["ls", "lc"]) -> None:
):
collected: dict[str, RunTree] = {}
def collect_run(run: RunTree) -> None:
def collect_langsmith_run(run: RunTree) -> None:
collected[str(run.id)] = run
def collect_tracer_run(_: LangChainTracer, run: RunTree) -> None:
collected[str(run.id)] = run
if parent_type == "ls":
@@ -476,7 +519,8 @@ def test_tree_is_constructed(parent_type: Literal["ls", "lc"]) -> None:
return child.invoke("foo")
assert (
parent(langsmith_extra={"on_end": collect_run, "run_id": rid}) == "foo"
parent(langsmith_extra={"on_end": collect_langsmith_run, "run_id": rid})
== "foo"
)
assert collected
@@ -487,9 +531,10 @@ def test_tree_is_constructed(parent_type: Literal["ls", "lc"]) -> None:
return child.invoke("foo")
tracer = LangChainTracer()
tracer._persist_run = collect_run # type: ignore[method-assign]
assert parent.invoke(..., {"run_id": rid, "callbacks": [tracer]}) == "foo" # type: ignore[attr-defined]
with patch.object(LangChainTracer, "_persist_run", new=collect_tracer_run):
assert (
parent.invoke(..., {"run_id": rid, "callbacks": [tracer]}) == "foo" # type: ignore[attr-defined]
)
run = collected.get(str(rid))
assert run is not None
@@ -508,3 +553,643 @@ def test_tree_is_constructed(parent_type: Literal["ls", "lc"]) -> None:
assert "afoo" in kitten_run.tags # type: ignore[operator]
assert grandchild_run is not None
assert kitten_run.dotted_order.startswith(grandchild_run.dotted_order)
class TestTracerMetadataThroughInvoke:
"""Tests for tracer metadata merging through invoke calls."""
def test_tracer_metadata_applied_to_all_runs(self) -> None:
"""Tracer metadata appears on every run when no config metadata is set."""
tracer = _create_tracer_with_mocked_client(
metadata={"env": "prod", "service": "api"}
)
@RunnableLambda
def child(x: int) -> int:
return x + 1
@RunnableLambda
def parent(x: int) -> int:
return child.invoke(x)
parent.invoke(1, {"callbacks": [tracer]})
posts = _get_posts(tracer.client)
assert len(posts) == 2
for post in posts:
md = post.get("extra", {}).get("metadata", {})
assert md.get("env") == "prod", f"run {post['name']} missing env"
assert md.get("service") == "api", f"run {post['name']} missing service"
def test_config_metadata_takes_precedence(self) -> None:
"""Config metadata wins over tracer metadata for overlapping keys."""
tracer = _create_tracer_with_mocked_client(
metadata={"env": "prod", "tracer_only": "yes"}
)
@RunnableLambda
def my_func(x: int) -> int:
return x
my_func.invoke(
1,
{
"callbacks": [tracer],
"metadata": {"env": "staging", "config_only": "yes"},
},
)
posts = _get_posts(tracer.client)
assert len(posts) == 1
md = posts[0].get("extra", {}).get("metadata", {})
# Config wins for overlapping key
assert md["env"] == "staging"
# Both non-overlapping keys are present
assert md["tracer_only"] == "yes"
assert md["config_only"] == "yes"
def test_nested_calls_inherit_config_metadata(self) -> None:
"""Child runs inherit config metadata; tracer metadata fills gaps."""
tracer = _create_tracer_with_mocked_client(
metadata={"tracer_key": "tracer_val"}
)
@RunnableLambda
def child(x: int) -> int:
return x + 1
@RunnableLambda
def parent(x: int) -> int:
return child.invoke(x)
parent.invoke(
1,
{
"callbacks": [tracer],
"metadata": {"config_key": "config_val"},
},
)
posts = _get_posts(tracer.client)
assert len(posts) == 2
name_to_md = {
post["name"]: post.get("extra", {}).get("metadata", {}) for post in posts
}
# Both parent and child should have config metadata (inherited)
# and tracer metadata (patched in)
for name, md in name_to_md.items():
assert md.get("config_key") == "config_val", f"{name} missing config_key"
assert md.get("tracer_key") == "tracer_val", f"{name} missing tracer_key"
def test_tracer_metadata_not_applied_to_sibling_handlers(self) -> None:
"""Tracer metadata is not applied to other callback handlers.
`_patch_missing_metadata` copies the metadata dict before patching,
so the callback manager's shared metadata dict is not mutated.
Other handlers should only see config metadata, not tracer metadata.
"""
tracer = _create_tracer_with_mocked_client(
metadata={"tracer_key": "tracer_val"}
)
received_metadata: list[dict[str, Any]] = []
class MetadataCapture(BaseCallbackHandler):
"""Callback handler that records metadata from chain events."""
def on_chain_start(self, *_args: Any, **kwargs: Any) -> None:
received_metadata.append(dict(kwargs.get("metadata", {})))
capture = MetadataCapture()
@RunnableLambda
def my_func(x: int) -> int:
return x
my_func.invoke(
1,
{
"callbacks": [tracer, capture],
"metadata": {"shared_key": "shared_val"},
},
)
assert len(received_metadata) >= 1
for md in received_metadata:
assert md["shared_key"] == "shared_val"
assert "tracer_key" not in md
# But the posted run DOES have tracer metadata
posts = _get_posts(tracer.client)
assert len(posts) >= 1
for post in posts:
post_md = post.get("extra", {}).get("metadata", {})
assert post_md["shared_key"] == "shared_val"
assert post_md["tracer_key"] == "tracer_val"
def test_tracer_metadata_with_no_config_metadata(self) -> None:
"""When no config metadata is set, tracer metadata is the sole source."""
tracer = _create_tracer_with_mocked_client(
metadata={"only_from_tracer": "value"}
)
@RunnableLambda
def my_func(x: int) -> int:
return x
my_func.invoke(1, {"callbacks": [tracer]})
posts = _get_posts(tracer.client)
assert len(posts) == 1
md = posts[0].get("extra", {}).get("metadata", {})
assert md["only_from_tracer"] == "value"
def test_empty_tracer_metadata_does_not_interfere(self) -> None:
"""Tracer with no metadata does not interfere with config metadata."""
tracer = _create_tracer_with_mocked_client(metadata=None)
@RunnableLambda
def my_func(x: int) -> int:
return x
my_func.invoke(
1,
{"callbacks": [tracer], "metadata": {"config_key": "config_val"}},
)
posts = _get_posts(tracer.client)
assert len(posts) == 1
md = posts[0].get("extra", {}).get("metadata", {})
assert md["config_key"] == "config_val"
def test_inheritable_metadata_nested_runs_preserve_parent_child_shape() -> None:
"""Concurrent nested runs keep parent-child linkage within each invocation."""
tracer = _create_tracer_with_mocked_client()
barrier = threading.Barrier(2)
@RunnableLambda
def child(x: int) -> int:
barrier.wait()
return x + 1
@RunnableLambda
def parent(x: int) -> int:
return child.invoke(x)
def invoke_for_tenant(tenant: str, value: int) -> int:
callbacks = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"tenant": tenant},
)
return parent.invoke(value, {"callbacks": callbacks})
threads = [
threading.Thread(target=invoke_for_tenant, args=("alpha", 1)),
threading.Thread(target=invoke_for_tenant, args=("beta", 2)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
posts = _get_posts(tracer.client)
assert len(posts) == 4
parents = [post for post in posts if post["name"] == "parent"]
children = [post for post in posts if post["name"] == "child"]
assert len(parents) == 2
assert len(children) == 2
parent_ids = {parent["id"] for parent in parents}
assert {child["parent_run_id"] for child in children} == parent_ids
assert {
post.get("extra", {}).get("metadata", {}).get("tenant") for post in posts
} == {
"alpha",
"beta",
}
def test_inheritable_metadata_parallel_children_keep_tenant_isolation() -> None:
"""Concurrent roots with parallel child runs keep tenant metadata isolated."""
tracer = _create_tracer_with_mocked_client()
barrier = threading.Barrier(4)
@RunnableLambda
def add_one(x: int) -> int:
barrier.wait()
return x + 1
@RunnableLambda
def add_two(x: int) -> int:
barrier.wait()
return x + 2
parallel = RunnableParallel(first=add_one, second=add_two)
def invoke_for_tenant(tenant: str, value: int) -> dict[str, int]:
callbacks = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"tenant": tenant},
)
return parallel.invoke(value, {"callbacks": callbacks})
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
list(executor.map(invoke_for_tenant, ["alpha", "beta"], [1, 2]))
posts = _get_posts(tracer.client)
assert len(posts) == 6
assert {
post.get("extra", {}).get("metadata", {}).get("tenant") for post in posts
} == {
"alpha",
"beta",
}
posts_by_trace: dict[str, list[dict[str, Any]]] = {}
for post in posts:
posts_by_trace.setdefault(post["trace_id"], []).append(post)
assert len(posts_by_trace) == 2
assert all(len(trace_posts) == 3 for trace_posts in posts_by_trace.values())
@pytest.mark.skipif(
sys.version_info < (3, 11), reason="Asyncio context vars require Python 3.11+"
)
async def test_langsmith_inheritable_metadata_mixed_sync_async_managers_isolated() -> (
None
):
"""Sync and async manager configure paths can overlap without metadata sharing."""
tracer = _create_tracer_with_mocked_client()
@RunnableLambda
async def async_runnable(x: int) -> int:
await asyncio.sleep(0)
return x + 1
@RunnableLambda
def sync_runnable(x: int) -> int:
return x + 1
async def run_sync() -> int:
callbacks = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"path": "sync"},
)
return await asyncio.to_thread(
sync_runnable.invoke, 1, {"callbacks": callbacks}
)
async def run_async() -> int:
callbacks = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"path": "async"},
)
return await async_runnable.ainvoke(1, {"callbacks": callbacks})
await asyncio.gather(run_sync(), run_async())
posts = _get_posts(tracer.client)
assert len(posts) == 2
assert {
post.get("extra", {}).get("metadata", {}).get("path") for post in posts
} == {
"sync",
"async",
}
class TestLangsmithInheritableTracingDefaultsInConfigure:
"""Tests for LangSmith inheritable tracing defaults in configure."""
def test_langsmith_inheritable_metadata_applied_via_configure(self) -> None:
"""langsmith_inheritable_metadata flows to a copied tracer."""
tracer = _create_tracer_with_mocked_client()
cm = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"env": "prod", "service": "api"},
)
lc_tracers = [h for h in cm.handlers if isinstance(h, LangChainTracer)]
assert len(lc_tracers) == 1
assert lc_tracers[0] is not tracer
assert lc_tracers[0].tracing_metadata == {"env": "prod", "service": "api"}
assert tracer.tracing_metadata is None
def test_langsmith_inheritable_metadata_does_not_overwrite_tracer_metadata(
self,
) -> None:
"""Tracer metadata takes precedence over langsmith_inheritable_metadata."""
tracer = _create_tracer_with_mocked_client(metadata={"env": "staging"})
cm = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"env": "prod", "service": "api"},
)
lc_tracer = next(h for h in cm.handlers if isinstance(h, LangChainTracer))
assert tracer.tracing_metadata == {"env": "staging"}
assert lc_tracer.tracing_metadata == {"env": "staging", "service": "api"}
def test_tracing_context_metadata_merged_into_langsmith_inheritable_metadata(
self,
) -> None:
"""Tracing-context metadata merges into tracer defaults.
LangSmith metadata keeps precedence on collisions.
"""
tracer = _create_tracer_with_mocked_client()
with tracing_context(
enabled=True,
client=tracer.client,
metadata={"trace_only": "value", "shared": "trace"},
):
cm = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={
"shared": "langsmith",
"tenant": "alpha",
},
)
lc_tracer = next(h for h in cm.handlers if isinstance(h, LangChainTracer))
assert lc_tracer.tracing_metadata == {
"trace_only": "value",
"shared": "langsmith",
"tenant": "alpha",
}
def test_langsmith_inheritable_metadata_end_to_end(self) -> None:
"""langsmith_inheritable_metadata in configure propagates to posted runs."""
tracer = _create_tracer_with_mocked_client()
@RunnableLambda
def my_func(x: int) -> int:
return x
# Use langsmith_inheritable_metadata through the config callbacks path
cm = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"env": "prod"},
)
my_func.invoke(1, {"callbacks": cm})
posts = _get_posts(tracer.client)
assert len(posts) == 1
md = posts[0].get("extra", {}).get("metadata", {})
assert md["env"] == "prod"
def test_runnable_config_copies_configurable_values_to_tracing_metadata(
self,
) -> None:
tracer = _create_tracer_with_mocked_client()
@RunnableLambda
def my_func(x: int) -> int:
return x
config: RunnableConfig = {
"callbacks": [tracer],
"metadata": {
"something": "else",
"checkpoint_ns": "from-metadata",
"model": "from-metadata",
},
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "from-configurable",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "from-configurable",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
"api_key": "should-not-propagate",
"__secret_key": "should-not-propagate",
"temperature": 0.5,
"streaming": True,
"custom_setting": {"nested": True},
"none_value": None,
},
}
my_func.invoke(1, config)
posts = _get_posts(tracer.client)
assert len(posts) == 1
md = posts[0].get("extra", {}).get("metadata", {})
assert {
key: md[key]
for key in (
"something",
"thread_id",
"checkpoint_id",
"task_id",
"run_id",
"assistant_id",
"graph_id",
"user_id",
"cron_id",
"langgraph_auth_user_id",
"temperature",
"streaming",
"model",
"checkpoint_ns",
)
} == {
"something": "else",
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
"temperature": 0.5,
"streaming": True,
"model": "from-metadata",
"checkpoint_ns": "from-metadata",
}
assert "api_key" not in md
assert "__secret_key" not in md
assert "custom_setting" not in md
assert "none_value" not in md
def test_langsmith_inheritable_metadata_does_not_affect_non_tracer_handlers(
self,
) -> None:
"""langsmith_inheritable_metadata only applies to tracers."""
tracer = _create_tracer_with_mocked_client()
received_metadata: list[dict[str, Any]] = []
class MetadataCapture(BaseCallbackHandler):
def on_chain_start(self, *_args: Any, **kwargs: Any) -> None:
received_metadata.append(dict(kwargs.get("metadata", {})))
capture = MetadataCapture()
cm = CallbackManager.configure(
inheritable_callbacks=[tracer, capture],
langsmith_inheritable_metadata={"tracer_only": "yes"},
)
@RunnableLambda
def my_func(x: int) -> int:
return x
my_func.invoke(1, {"callbacks": cm})
# Non-tracer handler should NOT see langsmith_inheritable_metadata
assert len(received_metadata) >= 1
for md in received_metadata:
assert "tracer_only" not in md
# But the tracer's posted runs SHOULD have it
posts = _get_posts(tracer.client)
assert len(posts) >= 1
for post in posts:
post_md = post.get("extra", {}).get("metadata", {})
assert post_md["tracer_only"] == "yes"
def test_no_langsmith_inheritable_metadata_is_noop(self) -> None:
"""Passing langsmith_inheritable_metadata=None does not alter tracer state."""
tracer = _create_tracer_with_mocked_client()
cm = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata=None,
)
lc_tracer = next(h for h in cm.handlers if isinstance(h, LangChainTracer))
assert lc_tracer is tracer
assert tracer.tracing_metadata is None
def test_langsmith_inheritable_tags_applied_via_configure(self) -> None:
"""langsmith_inheritable_tags flow to a copied tracer."""
tracer = _create_tracer_with_mocked_client()
tracer.tags = ["existing"]
cm = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_tags=["tenant:alpha", "existing"],
)
lc_tracer = next(h for h in cm.handlers if isinstance(h, LangChainTracer))
assert lc_tracer is not tracer
assert lc_tracer.tags == ["existing", "tenant:alpha"]
assert tracer.tags == ["existing"]
def test_inheritable_tags_do_not_affect_non_tracer_handlers(self) -> None:
"""langsmith_inheritable_tags only apply to tracers."""
tracer = _create_tracer_with_mocked_client()
received_tags: list[list[str]] = []
class TagCapture(BaseCallbackHandler):
def on_chain_start(self, *_args: Any, **kwargs: Any) -> None:
received_tags.append(list(kwargs.get("tags", [])))
capture = TagCapture()
cm = CallbackManager.configure(
inheritable_callbacks=[tracer, capture],
langsmith_inheritable_tags=["tracer-only"],
)
@RunnableLambda
def my_func(x: int) -> int:
return x
my_func.invoke(1, {"callbacks": cm})
assert received_tags
assert all("tracer-only" not in tags for tags in received_tags)
posts = _get_posts(tracer.client)
assert posts
assert all("tracer-only" in post.get("tags", []) for post in posts)
def test_langsmith_inheritable_metadata_copies_handlers_without_mutating_original(
self,
) -> None:
"""Configured manager copies tracers and leaves the original unchanged."""
tracer = _create_tracer_with_mocked_client()
cm = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"env": "prod"},
)
handler_tracer = next(h for h in cm.handlers if isinstance(h, LangChainTracer))
inheritable_tracer = next(
h for h in cm.inheritable_handlers if isinstance(h, LangChainTracer)
)
assert handler_tracer is not tracer
assert inheritable_tracer is not tracer
assert tracer.tracing_metadata is None
def test_langsmith_inheritable_metadata_configure_isolated_per_manager(
self,
) -> None:
"""Separate configure calls keep tracer-only defaults isolated."""
tracer = _create_tracer_with_mocked_client()
alpha_manager = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"tenant": "alpha"},
)
beta_manager = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"tenant": "beta"},
)
alpha_tracer = next(
handler
for handler in alpha_manager.handlers
if isinstance(handler, LangChainTracer)
)
beta_tracer = next(
handler
for handler in beta_manager.handlers
if isinstance(handler, LangChainTracer)
)
assert tracer.tracing_metadata is None
assert alpha_tracer is not tracer
assert beta_tracer is not tracer
assert alpha_tracer is not beta_tracer
assert alpha_tracer.tracing_metadata == {"tenant": "alpha"}
assert beta_tracer.tracing_metadata == {"tenant": "beta"}
assert alpha_tracer.run_map is tracer.run_map
assert beta_tracer.run_map is tracer.run_map
assert alpha_tracer.order_map is tracer.order_map
assert beta_tracer.order_map is tracer.order_map
def test_inheritable_metadata_concurrent_invocations_remain_isolated(
self,
) -> None:
"""Parallel invocations through copied tracers keep metadata separated."""
tracer = _create_tracer_with_mocked_client()
barrier = threading.Barrier(2)
@traceable
def traced_leaf(x: int) -> int:
barrier.wait()
return x
@RunnableLambda
def my_func(x: int) -> int:
return traced_leaf(x)
def invoke_for_tenant(tenant: str, value: int) -> int:
callbacks = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"tenant": tenant},
)
return my_func.invoke(value, {"callbacks": callbacks})
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
list(executor.map(invoke_for_tenant, ["alpha", "beta"], [1, 2]))
posts = _get_posts(tracer.client)
assert len(posts) == 4
assert {post["name"] for post in posts} == {"my_func", "traced_leaf"}
my_func_posts = [post for post in posts if post["name"] == "my_func"]
assert len(my_func_posts) == 2
assert {
post.get("extra", {}).get("metadata", {}).get("tenant")
for post in my_func_posts
} == {"alpha", "beta"}
assert tracer.run_map == {}
assert len(tracer.order_map) == 2

View File

@@ -1,3 +1,4 @@
import concurrent.futures
import threading
import time
import unittest.mock
@@ -15,6 +16,7 @@ from langchain_core.outputs import ChatGeneration, LLMResult
from langchain_core.tracers.langchain import (
LangChainTracer,
_get_usage_metadata_from_generations,
_patch_missing_metadata,
)
from langchain_core.tracers.schemas import Run
@@ -696,3 +698,206 @@ def test_on_chain_error_updates_when_not_defers_inputs() -> None:
# Should call update (PATCH), not persist (POST) for normal inputs
assert not persist_called
assert update_called
class TestPatchMissingMetadata:
"""Tests for `_patch_missing_metadata` and tracer metadata behavior."""
@staticmethod
def _make_tracer(
metadata: dict[str, str] | None = None,
) -> LangChainTracer:
client = unittest.mock.MagicMock(spec=Client)
client.tracing_queue = None
return LangChainTracer(client=client, metadata=metadata)
@staticmethod
def _make_run(
metadata: dict[str, Any] | None = None,
) -> Run:
return Run(
id=uuid.uuid4(),
name="test",
inputs={},
run_type="chain",
extra={"metadata": metadata or {}},
)
def test_adds_metadata_when_run_has_none(self) -> None:
"""Tracer metadata fills in when the run has no matching keys."""
tracer = self._make_tracer(metadata={"env": "prod", "service": "api"})
run = self._make_run()
_patch_missing_metadata(tracer, run)
assert run.metadata["env"] == "prod"
assert run.metadata["service"] == "api"
def test_does_not_overwrite_existing_keys(self) -> None:
"""Config metadata takes precedence over tracer metadata."""
tracer = self._make_tracer(metadata={"env": "prod", "service": "api"})
run = self._make_run(metadata={"env": "staging"})
_patch_missing_metadata(tracer, run)
assert run.metadata["env"] == "staging"
assert run.metadata["service"] == "api"
def test_noop_when_tracer_has_no_metadata(self) -> None:
"""No-op when the tracer has no metadata configured."""
tracer = self._make_tracer(metadata=None)
run = self._make_run(metadata={"existing": "value"})
_patch_missing_metadata(tracer, run)
assert run.metadata == {"existing": "value"}
def test_noop_when_all_keys_already_present(self) -> None:
"""No-op when every tracer key already exists in the run."""
tracer = self._make_tracer(metadata={"env": "prod"})
run = self._make_run(metadata={"env": "dev"})
_patch_missing_metadata(tracer, run)
assert run.metadata == {"env": "dev"}
def test_merges_disjoint_keys(self) -> None:
"""Disjoint keys from tracer and config are all present after patching."""
tracer = self._make_tracer(metadata={"tracer_key": "tracer_val"})
run = self._make_run(metadata={"config_key": "config_val"})
_patch_missing_metadata(tracer, run)
assert run.metadata == {
"tracer_key": "tracer_val",
"config_key": "config_val",
}
def test_persist_run_single_applies_tracer_metadata(self) -> None:
"""End-to-end: `_persist_run_single` calls `_patch_missing_metadata`."""
tracer = self._make_tracer(metadata={"env": "prod"})
run_id = UUID("9d878ab3-e5ca-4218-aef6-44cbdc90160a")
tracer.on_chain_start(
{"name": "test_chain"},
{"input": "hello"},
run_id=run_id,
)
run = tracer.run_map[str(run_id)]
with unittest.mock.patch.object(Run, "post"):
tracer._persist_run_single(run)
assert run.metadata.get("env") == "prod"
def test_persist_run_single_config_metadata_wins(self) -> None:
"""Config metadata is not overwritten by tracer metadata during persist."""
tracer = self._make_tracer(metadata={"env": "prod", "extra": "from_tracer"})
run_id = UUID("9d878ab3-e5ca-4218-aef6-44cbdc90160b")
tracer.on_chain_start(
{"name": "test_chain"},
{"input": "hello"},
run_id=run_id,
metadata={"env": "staging"},
)
run = tracer.run_map[str(run_id)]
with unittest.mock.patch.object(Run, "post"):
tracer._persist_run_single(run)
assert run.metadata["env"] == "staging"
assert run.metadata["extra"] == "from_tracer"
class TestTracerMetadataCloning:
"""Tests for LangChainTracer metadata cloning helpers."""
@staticmethod
def _make_tracer(
metadata: dict[str, str] | None = None,
) -> LangChainTracer:
client = unittest.mock.MagicMock(spec=Client)
client.tracing_queue = None
return LangChainTracer(client=client, metadata=metadata)
def test_copy_with_metadata_defaults_copies_configuration(self) -> None:
"""Copied tracer keeps stable configuration but not identity."""
tracer = self._make_tracer(metadata={"env": "staging"})
tracer.project_name = "project"
tracer.tags = ["tag"]
copied = tracer.copy_with_metadata_defaults(metadata={"service": "api"})
assert copied is not tracer
assert copied.client is tracer.client
assert copied.project_name == "project"
assert copied.tags == ["tag"]
assert copied.tags is tracer.tags
assert copied.tracing_metadata == {"env": "staging", "service": "api"}
assert copied.run_map is tracer.run_map
assert copied.order_map is tracer.order_map
assert copied.run_has_token_event_map == {}
def test_copy_with_metadata_defaults_does_not_mutate_original(self) -> None:
"""Metadata-default cloning leaves the source tracer unchanged."""
tracer = self._make_tracer(metadata={"env": "staging"})
copied = tracer.copy_with_metadata_defaults(metadata={"service": "api"})
assert tracer.tracing_metadata == {"env": "staging"}
assert copied.tracing_metadata == {"env": "staging", "service": "api"}
def test_copy_with_metadata_defaults_none_preserves_configuration(self) -> None:
"""Copying without new metadata preserves metadata and shared run state."""
tracer = self._make_tracer(metadata={"env": "staging"})
copied = tracer.copy_with_metadata_defaults(metadata=None)
assert copied is not tracer
assert copied.tracing_metadata == {"env": "staging"}
assert copied.run_map is tracer.run_map
assert copied.order_map is tracer.order_map
def test_copy_with_metadata_defaults_threadsafe(self) -> None:
"""Concurrent metadata-default copies do not mutate each other or the source."""
tracer = self._make_tracer(metadata={"env": "staging"})
def copy_for_service(service: str) -> dict[str, str]:
copied = tracer.copy_with_metadata_defaults(metadata={"service": service})
assert copied is not tracer
return copied.tracing_metadata or {}
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
metadata_values = list(executor.map(copy_for_service, ["api", "worker"]))
assert tracer.tracing_metadata == {"env": "staging"}
assert {metadata["service"] for metadata in metadata_values} == {
"api",
"worker",
}
assert all(metadata["env"] == "staging" for metadata in metadata_values)
def test_copy_with_metadata_defaults_threadsafe_with_existing_shared_state(
self,
) -> None:
"""Concurrent copies preserve pre-populated shared run state."""
tracer = self._make_tracer(metadata={"env": "staging"})
run_id = uuid.uuid4()
tracer.run_map["existing"] = unittest.mock.MagicMock()
tracer.order_map[run_id] = (run_id, f"prefix.{run_id}")
def copy_for_service(service: str) -> LangChainTracer:
copied = tracer.copy_with_metadata_defaults(metadata={"service": service})
assert copied.run_map is tracer.run_map
assert copied.order_map is tracer.order_map
return copied
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
copied_tracers = list(executor.map(copy_for_service, ["api", "worker"]))
assert tracer.run_map.keys() == {"existing"}
assert tracer.order_map == {run_id: (run_id, f"prefix.{run_id}")}
copied_services = {
copied.tracing_metadata["service"]
for copied in copied_tracers
if copied.tracing_metadata is not None
}
assert copied_services == {"api", "worker"}

2
libs/core/uv.lock generated
View File

@@ -995,7 +995,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.28"
version = "1.3.0a1"
source = { editable = "." }
dependencies = [
{ name = "jsonpatch" },

View File

@@ -1,4 +1,4 @@
.PHONY: all start_services stop_services coverage coverage_agents test test_fast extended_tests test_watch test_watch_extended integration_tests check_imports check_version lint format type lint_diff format_diff lint_package lint_tests help
.PHONY: all start_services stop_services coverage coverage_agents test test_fast benchmark extended_tests test_watch test_watch_extended integration_tests check_imports check_version lint format type lint_diff format_diff lint_package lint_tests help
# Default target executed when no arguments are given to make.
all: help
@@ -38,13 +38,16 @@ coverage_agents:
--cov-report=html:htmlcov \
test:
make start_services && LANGGRAPH_TEST_FAST=0 uv run --no-sync --active --group test pytest -n auto $(PYTEST_EXTRA) --disable-socket --allow-unix-socket $(TEST_FILE) --cov-report term-missing:skip-covered --snapshot-update; \
make start_services && LANGGRAPH_TEST_FAST=0 uv run --no-sync --active --group test pytest -n auto $(PYTEST_EXTRA) --benchmark-disable --disable-socket --allow-unix-socket $(TEST_FILE) --cov-report term-missing:skip-covered --snapshot-update; \
EXIT_CODE=$$?; \
make stop_services; \
exit $$EXIT_CODE
test_fast:
LANGGRAPH_TEST_FAST=1 uv run --group test pytest -n auto $(PYTEST_EXTRA) --disable-socket --allow-unix-socket $(TEST_FILE)
LANGGRAPH_TEST_FAST=1 uv run --group test pytest -n auto $(PYTEST_EXTRA) --benchmark-disable --disable-socket --allow-unix-socket $(TEST_FILE)
benchmark:
uv run --group test pytest tests/benchmarks/test_create_agent.py -m benchmark
extended_tests:
make start_services && LANGGRAPH_TEST_FAST=0 uv run --group test pytest --disable-socket --allow-unix-socket --only-extended tests/unit_tests; \
@@ -117,6 +120,7 @@ help:
@echo 'coverage_agents - run middleware and agent tests with coverage report'
@echo 'test - run unit tests with all services'
@echo 'test_fast - run unit tests with in-memory services only'
@echo 'benchmark - run the create_agent benchmark quickly'
@echo 'tests - run unit tests (alias for "make test")'
@echo 'test TEST_FILE=<test_file> - run all tests in file'
@echo 'extended_tests - run only extended unit tests'

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import functools
import itertools
from dataclasses import dataclass, field, fields
from typing import (
@@ -399,9 +400,15 @@ def _chain_async_model_call_handlers(
return composed_handler
@functools.lru_cache(maxsize=100)
def _get_schema_type_hints(schema: type) -> dict[str, Any]:
"""Return cached type hints for a schema."""
return get_type_hints(schema, include_extras=True)
def _resolve_schemas(schemas: set[type]) -> tuple[type, type, type]:
"""Resolve state, input, and output schemas for the given schemas."""
schema_hints = {schema: get_type_hints(schema, include_extras=True) for schema in schemas}
schema_hints = {schema: _get_schema_type_hints(schema) for schema in schemas}
return (
_resolve_schema(schema_hints, "StateSchema", None),
_resolve_schema(schema_hints, "InputSchema", "input"),

View File

@@ -68,6 +68,7 @@ test = [
"pytest-socket>=0.6.0,<1.0.0",
"pytest-xdist<4.0.0,>=3.6.1",
"pytest-mock",
"pytest-benchmark>=5.1.0,<6.0.0",
"syrupy>=4.0.2,<6.0.0",
"toml>=0.10.2,<1.0.0",
"blockbuster>=1.5.26,<1.6.0",
@@ -189,6 +190,7 @@ markers = [
"requires: mark tests as requiring a specific library",
"scheduled: mark tests to run in scheduled testing",
"compile: mark placeholder test used to compile integration tests without running them",
"benchmark: mark benchmark tests",
]
asyncio_mode = "auto"
filterwarnings = [

View File

@@ -0,0 +1,44 @@
from typing import TYPE_CHECKING, Any
import pytest
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from pytest_benchmark.fixture import BenchmarkFixture
from langchain.agents import create_agent
from langchain.agents.middleware import (
ModelRetryMiddleware,
TodoListMiddleware,
ToolRetryMiddleware,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from langchain.agents.middleware import AgentMiddleware
@pytest.mark.benchmark
def test_create_agent_instantiation(benchmark: BenchmarkFixture) -> None:
def instantiate_agent() -> None:
create_agent(model=GenericFakeChatModel(messages=iter([AIMessage(content="ok")])))
benchmark(instantiate_agent)
@pytest.mark.benchmark
def test_create_agent_instantiation_with_middleware(
benchmark: BenchmarkFixture,
) -> None:
def instantiate_agent() -> None:
middleware: Sequence[AgentMiddleware[Any, Any]] = (
TodoListMiddleware(),
ToolRetryMiddleware(),
ModelRetryMiddleware(),
)
create_agent(
model=GenericFakeChatModel(messages=iter([AIMessage(content="ok")])),
middleware=middleware,
)
benchmark(instantiate_agent)

View File

@@ -1,5 +1,5 @@
import os
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from unittest import mock
import pytest
@@ -171,7 +171,7 @@ def test_configurable() -> None:
for method in ("get_num_tokens", "get_num_tokens_from_messages"):
assert hasattr(model_with_config, method)
assert model_with_config.model_dump() == { # type: ignore[attr-defined]
expected: dict[str, Any] = {
"name": None,
"bound": {
"name": None,
@@ -234,6 +234,7 @@ def test_configurable() -> None:
"custom_input_type": None,
"custom_output_type": None,
}
assert model_with_config.model_dump() == expected # type: ignore[attr-defined]
@pytest.mark.requires("langchain_openai", "langchain_anthropic")
@@ -297,7 +298,7 @@ def test_configurable_with_default() -> None:
assert model_with_config.model == "claude-sonnet-4-5-20250929" # type: ignore[attr-defined]
assert model_with_config.model_dump() == { # type: ignore[attr-defined]
expected: dict[str, Any] = {
"name": None,
"bound": {
"name": None,
@@ -332,7 +333,7 @@ def test_configurable_with_default() -> None:
"config": {
"callbacks": None,
"configurable": {},
"metadata": {"bar_model": "claude-sonnet-4-5-20250929"},
"metadata": {},
"recursion_limit": 25,
"tags": ["foo"],
},
@@ -340,6 +341,7 @@ def test_configurable_with_default() -> None:
"custom_input_type": None,
"custom_output_type": None,
}
assert model_with_config.model_dump() == expected # type: ignore[attr-defined]
prompt = ChatPromptTemplate.from_messages([("system", "foo")])
chain = prompt | model_with_config
assert isinstance(chain, RunnableSequence)

View File

@@ -1986,6 +1986,7 @@ test = [
{ name = "langchain-tests" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-benchmark" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
{ name = "pytest-socket" },
@@ -2040,6 +2041,7 @@ test = [
{ name = "langchain-tests", editable = "../standard-tests" },
{ name = "pytest", specifier = ">=8.0.0,<10.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.2,<2.0.0" },
{ name = "pytest-benchmark", specifier = ">=5.1.0,<6.0.0" },
{ name = "pytest-cov", specifier = ">=4.0.0,<8.0.0" },
{ name = "pytest-mock" },
{ name = "pytest-socket", specifier = ">=0.6.0,<1.0.0" },

View File

@@ -41,10 +41,11 @@ class AnthropicPromptCachingMiddleware(AgentMiddleware):
Applies cache control breakpoints to:
- **System message**: Tags the last content block of the system message
with `cache_control` so static system prompt content is cached.
- **Tools**: Tags all tool definitions with `cache_control` so tool
schemas are cached across turns.
- **System message**: Tags system message content blocks with
`cache_control`. When multiple blocks exist, tags both the
second-to-last and last blocks for finer-grained cache hits.
- **Tools**: Tags the last tool definition with `cache_control` so
tool schemas are cached across turns.
- **Last cacheable block**: Tags last cacheable block of message sequence using
Anthropic's automatic caching feature.
@@ -186,7 +187,14 @@ def _tag_system_message(
system_message: Any,
cache_control: dict[str, str],
) -> Any:
"""Tag the last content block of a system message with cache_control.
"""Tag system message content blocks with cache_control.
When the system message has multiple content blocks (e.g. a static
base prompt and dynamic memory), tags both the second-to-last and
last blocks. This creates two cache breakpoints so that when the
last block changes, the earlier blocks still get cache hits.
When there is only one block, tags that block.
Returns the original system_message unchanged if there are no blocks
to tag.
@@ -196,8 +204,8 @@ def _tag_system_message(
cache_control: The cache control dict to apply.
Returns:
A new SystemMessage with cache_control on the last block, or the
original if no modification was needed.
A new SystemMessage with cache_control applied, or the original
if no modification was needed.
"""
if system_message is None:
return system_message
@@ -213,6 +221,10 @@ def _tag_system_message(
if not content:
return system_message
new_content = list(content)
if len(new_content) >= 2:
second_last = new_content[-2]
base2 = second_last if isinstance(second_last, dict) else {}
new_content[-2] = {**base2, "cache_control": cache_control}
last = new_content[-1]
base = last if isinstance(last, dict) else {}
new_content[-1] = {**base, "cache_control": cache_control}

View File

@@ -136,6 +136,26 @@ _PROFILES: dict[str, dict[str, Any]] = {
"attachment": False,
"temperature": True,
},
"accounts/fireworks/models/glm-5p1": {
"name": "GLM 5.1",
"release_date": "2026-04-01",
"last_updated": "2026-04-01",
"open_weights": True,
"max_input_tokens": 202800,
"max_output_tokens": 131072,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"attachment": False,
"temperature": True,
},
"accounts/fireworks/models/gpt-oss-120b": {
"name": "GPT OSS 120B",
"release_date": "2025-08-05",
@@ -276,6 +296,26 @@ _PROFILES: dict[str, dict[str, Any]] = {
"attachment": False,
"temperature": True,
},
"accounts/fireworks/models/qwen3p6-plus": {
"name": "Qwen 3.6 Plus",
"release_date": "2026-04-04",
"last_updated": "2026-04-04",
"open_weights": False,
"max_input_tokens": 128000,
"max_output_tokens": 8192,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"attachment": True,
"temperature": True,
},
"accounts/fireworks/routers/kimi-k2p5-turbo": {
"name": "Kimi K2.5 Turbo (firepass)",
"release_date": "2026-01-27",

View File

@@ -1070,6 +1070,27 @@ _PROFILES: dict[str, dict[str, Any]] = {
"attachment": True,
"temperature": True,
},
"google/gemma-4-26b-a4b-it:free": {
"name": "Gemma 4 26B A4B (free)",
"release_date": "2026-04-03",
"last_updated": "2026-04-03",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 32768,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"video_inputs": True,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": True,
},
"google/gemma-4-31b-it": {
"name": "Gemma 4 31B",
"release_date": "2026-04-02",
@@ -1091,6 +1112,27 @@ _PROFILES: dict[str, dict[str, Any]] = {
"attachment": True,
"temperature": True,
},
"google/gemma-4-31b-it:free": {
"name": "Gemma 4 31B (free)",
"release_date": "2026-04-02",
"last_updated": "2026-04-02",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 32768,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"video_inputs": True,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": True,
},
"inception/mercury": {
"name": "Mercury",
"release_date": "2025-06-26",
@@ -2965,8 +3007,8 @@ _PROFILES: dict[str, dict[str, Any]] = {
"attachment": True,
"temperature": True,
},
"qwen/qwen3.6-plus:free": {
"name": "Qwen3.6 Plus (free)",
"qwen/qwen3.6-plus": {
"name": "Qwen3.6 Plus",
"release_date": "2026-04-02",
"last_updated": "2026-04-02",
"open_weights": False,