Compare commits

..

1 Commits

Author SHA1 Message Date
jacoblee93
4e0b69f506 Tag middleware runs with ls_agent_type 2026-04-17 22:02:09 -07:00
16 changed files with 264 additions and 650 deletions

1
.gitignore vendored
View File

@@ -166,4 +166,3 @@ virtualenv/
scratch/
.langgraph_api/
.worktrees/

View File

@@ -82,6 +82,18 @@ from langchain_core.runnables.utils import (
is_async_generator,
)
from langchain_core.tracers._streaming import _StreamingCallbackHandler
from langchain_core.tracers.event_stream import (
_astream_events_implementation_v1,
_astream_events_implementation_v2,
)
from langchain_core.tracers.log_stream import (
LogStreamCallbackHandler,
_astream_log_implementation,
)
from langchain_core.tracers.root_listeners import (
AsyncRootListenersTracer,
RootListenersTracer,
)
from langchain_core.utils.aiter import aclosing, atee
from langchain_core.utils.iter import safetee
from langchain_core.utils.pydantic import create_model_v2
@@ -99,21 +111,8 @@ if TYPE_CHECKING:
from langchain_core.runnables.retry import ExponentialJitterParams
from langchain_core.runnables.schema import StreamEvent
from langchain_core.tools import BaseTool
from langchain_core.tracers.event_stream import (
_astream_events_implementation_v1, # noqa: F401
_astream_events_implementation_v2, # noqa: F401
)
from langchain_core.tracers.log_stream import (
LogStreamCallbackHandler, # noqa: F401
RunLog,
RunLogPatch,
_astream_log_implementation, # noqa: F401
)
from langchain_core.tracers.root_listeners import (
AsyncListener,
AsyncRootListenersTracer, # noqa: F401
RootListenersTracer, # noqa: F401
)
from langchain_core.tracers.log_stream import RunLog, RunLogPatch
from langchain_core.tracers.root_listeners import AsyncListener
from langchain_core.tracers.schemas import Run
@@ -1246,11 +1245,6 @@ class Runnable(ABC, Generic[Input, Output]):
A `RunLogPatch` or `RunLog` object.
"""
from langchain_core.tracers.log_stream import ( # noqa: PLC0415
LogStreamCallbackHandler,
_astream_log_implementation,
)
stream = LogStreamCallbackHandler(
auto_close=False,
include_names=include_names,
@@ -1486,11 +1480,6 @@ class Runnable(ABC, Generic[Input, Output]):
NotImplementedError: If the version is not `'v1'` or `'v2'`.
""" # noqa: E501
from langchain_core.tracers.event_stream import ( # noqa: PLC0415
_astream_events_implementation_v1,
_astream_events_implementation_v2,
)
if version == "v2":
event_stream = _astream_events_implementation_v2(
self,
@@ -1733,10 +1722,6 @@ class Runnable(ABC, Generic[Input, Output]):
chain.invoke(2)
```
"""
from langchain_core.tracers.root_listeners import ( # noqa: PLC0415
RootListenersTracer,
)
return RunnableBinding(
bound=self,
config_factories=[
@@ -1834,10 +1819,6 @@ class Runnable(ABC, Generic[Input, Output]):
# on end callback ends at 2025-03-01T07:05:30.884831+00:00
```
"""
from langchain_core.tracers.root_listeners import ( # noqa: PLC0415
AsyncRootListenersTracer,
)
return RunnableBinding(
bound=self,
config_factories=[
@@ -6068,9 +6049,6 @@ class RunnableBinding(RunnableBindingBase[Input, Output]): # type: ignore[no-re
Returns:
A new `Runnable` with the listeners bound.
"""
from langchain_core.tracers.root_listeners import ( # noqa: PLC0415
RootListenersTracer,
)
def listener_config_factory(config: RunnableConfig) -> RunnableConfig:
return {

View File

@@ -552,15 +552,8 @@ class ChildTool(BaseTool):
model_config = ConfigDict(
arbitrary_types_allowed=True,
ignored_types=(functools.cached_property,),
)
def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
if name in {"args_schema", "description", "name"}:
self.__dict__.pop("tool_call_schema", None)
self.__dict__.pop("args", None)
@property
def is_single_input(self) -> bool:
"""Check if the tool accepts only a single input argument.
@@ -571,7 +564,7 @@ class ChildTool(BaseTool):
keys = {k for k in self.args if k != "kwargs"}
return len(keys) == 1
@functools.cached_property
@property
def args(self) -> dict:
"""Get the tool's input arguments schema.
@@ -590,7 +583,7 @@ class ChildTool(BaseTool):
json_schema = input_schema.model_json_schema()
return cast("dict", json_schema["properties"])
@functools.cached_property
@property
def tool_call_schema(self) -> ArgsSchema:
"""Get the schema for tool calls, excluding injected arguments.

View File

@@ -340,18 +340,17 @@ def _format_tool_to_openai_function(tool: BaseTool) -> FunctionDescription:
is_simple_oai_tool = (
isinstance(tool, langchain_core.tools.simple.Tool) and not tool.args_schema
)
schema = tool.tool_call_schema
if schema and not is_simple_oai_tool:
if isinstance(schema, dict):
if tool.tool_call_schema and not is_simple_oai_tool:
if isinstance(tool.tool_call_schema, dict):
return _convert_json_schema_to_openai_function(
schema, name=tool.name, description=tool.description
tool.tool_call_schema, name=tool.name, description=tool.description
)
if issubclass(schema, (BaseModel, BaseModelV1)):
if issubclass(tool.tool_call_schema, (BaseModel, BaseModelV1)):
return _convert_pydantic_to_openai_function(
schema, name=tool.name, description=tool.description
tool.tool_call_schema, name=tool.name, description=tool.description
)
error_msg = (
f"Unsupported tool call schema: {schema}. "
f"Unsupported tool call schema: {tool.tool_call_schema}. "
"Tool call schema must be a JSON schema dict or a Pydantic model."
)
raise ValueError(error_msg)

View File

@@ -199,21 +199,18 @@ class _IgnoreUnserializable(GenerateJsonSchema):
return {}
@lru_cache(maxsize=256)
def _create_subset_model_v1(
name: str,
model: type[BaseModelV1],
field_names: tuple[str, ...],
field_names: list,
*,
descriptions: tuple[tuple[str, str], ...] | None = None,
descriptions: dict | None = None,
fn_description: str | None = None,
) -> type[BaseModelV1]:
"""Create a Pydantic model with only a subset of model's fields."""
descriptions_ = dict(descriptions) if descriptions else {}
field_names_list = list(field_names)
fields = {}
for field_name in field_names_list:
for field_name in field_names:
# Using pydantic v1 so can access __fields__ as a dict.
field = model.__fields__[field_name]
t = (
@@ -222,8 +219,8 @@ def _create_subset_model_v1(
if field.required and not field.allow_none
else field.outer_type_ | None
)
if descriptions_ and field_name in descriptions_:
field.field_info.description = descriptions_[field_name]
if descriptions and field_name in descriptions:
field.field_info.description = descriptions[field_name]
fields[field_name] = (t, field.field_info)
rtn = cast("type[BaseModelV1]", create_model_v1(name, **fields)) # type: ignore[call-overload]
@@ -231,20 +228,18 @@ def _create_subset_model_v1(
return rtn
@lru_cache(maxsize=256)
def _create_subset_model_v2(
name: str,
model: type[BaseModel],
field_names: tuple[str, ...],
field_names: list[str],
*,
descriptions: tuple[tuple[str, str], ...] | None = None,
descriptions: dict | None = None,
fn_description: str | None = None,
) -> type[BaseModel]:
"""Create a Pydantic model with a subset of the model fields."""
descriptions_ = dict(descriptions) if descriptions else {}
field_names_list = list(field_names)
descriptions_ = descriptions or {}
fields = {}
for field_name in field_names_list:
for field_name in field_names:
field = model.model_fields[field_name]
description = descriptions_.get(field_name, field.description)
field_kwargs: dict[str, Any] = {"description": description}
@@ -296,20 +291,19 @@ def _create_subset_model(
Returns:
The created subset model.
"""
descriptions_tuple = tuple(descriptions.items()) if descriptions else None
if issubclass(model, BaseModelV1):
return _create_subset_model_v1(
name,
model,
tuple(field_names),
descriptions=descriptions_tuple,
field_names,
descriptions=descriptions,
fn_description=fn_description,
)
return _create_subset_model_v2(
name,
model,
tuple(field_names),
descriptions=descriptions_tuple,
field_names,
descriptions=descriptions,
fn_description=fn_description,
)

View File

@@ -3653,42 +3653,3 @@ def test_tool_default_factory_not_required() -> None:
schema = convert_to_openai_tool(some_func)
params = schema["function"]["parameters"]
assert "names" not in params.get("required", [])
def test_tool_call_schema_cache_invalidated_on_field_mutation() -> None:
"""Verify cached tool_call_schema is invalidated when schema-affecting fields change."""
@tool
def my_tool(x: int) -> str:
"""Original description."""
return str(x)
schema_before = my_tool.tool_call_schema
my_tool.description = "Updated description"
schema_after = my_tool.tool_call_schema
assert schema_before is not schema_after
assert schema_after.__doc__ == "Updated description"
def test_args_cache_invalidated_on_args_schema_change() -> None:
"""Verify cached args is invalidated when args_schema changes."""
class SchemaA(BaseModel):
x: int
class SchemaB(BaseModel):
y: str
@tool(args_schema=SchemaA)
def my_tool(x: int) -> str:
"""A tool."""
return str(x)
args_before = my_tool.args
assert "x" in args_before
my_tool.args_schema = SchemaB
args_after = my_tool.args
assert "y" in args_after
assert "x" not in args_after

View File

@@ -115,7 +115,7 @@ def test_with_field_metadata() -> None:
description="List of integers", min_length=10, max_length=15
)
subset_model = _create_subset_model_v2("Foo", Foo, ("x",))
subset_model = _create_subset_model_v2("Foo", Foo, ["x"])
assert subset_model.model_json_schema() == {
"properties": {
"x": {
@@ -199,7 +199,7 @@ def test_create_subset_model_v2_preserves_default_factory() -> None:
subset = _create_subset_model_v2(
"Subset",
Original,
("required_field", "names", "mapping"),
["required_field", "names", "mapping"],
)
schema = subset.model_json_schema()
assert schema.get("required") == ["required_field"]

View File

@@ -896,9 +896,11 @@ def create_agent(
wrap_tool_call_wrapper = None
if middleware_w_wrap_tool_call:
wrappers = [
traceable(name=f"{m.name}.wrap_tool_call", process_inputs=_scrub_inputs)(
m.wrap_tool_call
)
traceable(
name=f"{m.name}.wrap_tool_call",
process_inputs=_scrub_inputs,
metadata={"ls_agent_type": "middleware"},
)(m.wrap_tool_call)
for m in middleware_w_wrap_tool_call
]
wrap_tool_call_wrapper = _chain_tool_call_wrappers(wrappers)
@@ -917,9 +919,11 @@ def create_agent(
awrap_tool_call_wrapper = None
if middleware_w_awrap_tool_call:
async_wrappers = [
traceable(name=f"{m.name}.awrap_tool_call", process_inputs=_scrub_inputs)(
m.awrap_tool_call
)
traceable(
name=f"{m.name}.awrap_tool_call",
process_inputs=_scrub_inputs,
metadata={"ls_agent_type": "middleware"},
)(m.awrap_tool_call)
for m in middleware_w_awrap_tool_call
]
awrap_tool_call_wrapper = _chain_async_tool_call_wrappers(async_wrappers)
@@ -1005,9 +1009,11 @@ def create_agent(
wrap_model_call_handler = None
if middleware_w_wrap_model_call:
sync_handlers = [
traceable(name=f"{m.name}.wrap_model_call", process_inputs=_scrub_inputs)(
m.wrap_model_call
)
traceable(
name=f"{m.name}.wrap_model_call",
process_inputs=_scrub_inputs,
metadata={"ls_agent_type": "middleware"},
)(m.wrap_model_call)
for m in middleware_w_wrap_model_call
]
wrap_model_call_handler = _chain_model_call_handlers(sync_handlers)
@@ -1016,9 +1022,11 @@ def create_agent(
awrap_model_call_handler = None
if middleware_w_awrap_model_call:
async_handlers = [
traceable(name=f"{m.name}.awrap_model_call", process_inputs=_scrub_inputs)(
m.awrap_model_call
)
traceable(
name=f"{m.name}.awrap_model_call",
process_inputs=_scrub_inputs,
metadata={"ls_agent_type": "middleware"},
)(m.awrap_model_call)
for m in middleware_w_awrap_model_call
]
awrap_model_call_handler = _chain_async_model_call_handlers(async_handlers)

View File

@@ -69,7 +69,6 @@ test = [
"pytest-xdist<4.0.0,>=3.6.1",
"pytest-mock",
"pytest-benchmark>=5.1.0,<6.0.0",
"pytest-codspeed>=3.0.0,<4.0.0",
"syrupy>=5.0.0,<6.0.0",
"toml>=0.10.2,<1.0.0",
"blockbuster>=1.5.26,<1.6.0",

View File

@@ -1,14 +1,8 @@
from __future__ import annotations
import tracemalloc
from itertools import cycle
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 langchain_core.tools import tool
from pydantic import BaseModel, Field
from pytest_benchmark.fixture import BenchmarkFixture
from langchain.agents import create_agent
@@ -24,314 +18,27 @@ if TYPE_CHECKING:
from langchain.agents.middleware import AgentMiddleware
# ---------------------------------------------------------------------------
# Tool fixtures — a realistic mix of simple, structured, and nested schemas
# ---------------------------------------------------------------------------
@tool
def simple_tool_1(x: int) -> str:
"""Add one to a number."""
return str(x + 1)
@tool
def simple_tool_2(text: str) -> str:
"""Reverse a string."""
return text[::-1]
@tool
def simple_tool_3(a: float, b: float) -> float:
"""Multiply two numbers."""
return a * b
@tool
def simple_tool_4(items: list[str]) -> int:
"""Count items in a list."""
return len(items)
@tool
def simple_tool_5(flag: bool, value: str) -> str:
"""Return value if flag is true."""
return value if flag else ""
class AddressSchema(BaseModel):
street: str = Field(description="Street address")
city: str = Field(description="City name")
zip_code: str = Field(description="ZIP code")
class PersonSchema(BaseModel):
name: str = Field(description="Full name")
age: int = Field(description="Age in years")
address: AddressSchema = Field(description="Home address")
tags: list[str] = Field(default_factory=list, description="Tags")
@tool(args_schema=PersonSchema)
def structured_tool_1(name: str, age: int, address: AddressSchema, tags: list[str]) -> str:
"""Look up a person by their details."""
return f"{name}, {age}"
class SearchSchema(BaseModel):
query: str = Field(description="Search query string")
max_results: int = Field(default=10, description="Maximum results to return")
filters: dict[str, str] = Field(default_factory=dict, description="Filter map")
@tool(args_schema=SearchSchema)
def structured_tool_2(query: str, max_results: int, filters: dict[str, str]) -> list[str]:
"""Search a database with filters."""
return [query] * min(max_results, 3)
class FileSchema(BaseModel):
path: str = Field(description="File path")
encoding: str = Field(default="utf-8", description="File encoding")
lines: list[int] = Field(default_factory=list, description="Line numbers to read")
@tool(args_schema=FileSchema)
def structured_tool_3(path: str, encoding: str, lines: list[int]) -> str:
"""Read a file at a given path."""
return f"content of {path}"
class MatrixSchema(BaseModel):
rows: int = Field(description="Number of rows")
cols: int = Field(description="Number of columns")
fill: float = Field(default=0.0, description="Fill value")
@tool(args_schema=MatrixSchema)
def structured_tool_4(rows: int, cols: int, fill: float) -> list[list[float]]:
"""Create a matrix filled with a value."""
return [[fill] * cols for _ in range(rows)]
class CoordinateSchema(BaseModel):
lat: float = Field(description="Latitude")
lon: float = Field(description="Longitude")
class LocationSchema(BaseModel):
name: str = Field(description="Location name")
coordinate: CoordinateSchema = Field(description="GPS coordinate")
altitude_m: float = Field(default=0.0, description="Altitude in meters")
class RouteSchema(BaseModel):
origin: LocationSchema = Field(description="Starting location")
destination: LocationSchema = Field(description="Ending location")
waypoints: list[LocationSchema] = Field(default_factory=list, description="Intermediate stops")
max_distance_km: float = Field(default=1000.0, description="Maximum route distance")
@tool(args_schema=RouteSchema)
def deep_nested_tool(
origin: LocationSchema,
destination: LocationSchema,
waypoints: list[LocationSchema],
max_distance_km: float,
) -> dict[str, Any]:
"""Plan a route between locations with deep nested schema."""
return {"origin": origin.name, "destination": destination.name}
@tool
def complex_tool_1(
name: str,
metadata: dict[str, Any],
tags: list[str],
priority: int = 0,
) -> dict[str, Any]:
"""Create an item with metadata."""
return {"name": name, "metadata": metadata, "tags": tags, "priority": priority}
@tool
def complex_tool_2(
source: str,
destination: str,
options: dict[str, str] | None = None,
) -> bool:
"""Copy data from source to destination."""
return True
@tool
def complex_tool_3(
ids: list[int],
batch_size: int = 10,
retry: bool = False,
) -> dict[str, list[int]]:
"""Process a batch of IDs."""
return {"processed": ids[:batch_size]}
@tool
def complex_tool_4(
expression: str,
variables: dict[str, float],
precision: int = 6,
) -> float:
"""Evaluate a mathematical expression."""
return 0.0
@tool
def complex_tool_5(
url: str,
method: str = "GET",
headers: dict[str, str] | None = None,
body: str | None = None,
timeout: int = 30,
) -> dict[str, Any]:
"""Make an HTTP request."""
return {"status": 200, "body": ""}
SMALL_TOOLS = [simple_tool_1, simple_tool_2, simple_tool_3]
MEDIUM_TOOLS = [
simple_tool_1,
simple_tool_2,
simple_tool_3,
simple_tool_4,
simple_tool_5,
structured_tool_1,
structured_tool_2,
]
LARGE_TOOLS = [
simple_tool_1,
simple_tool_2,
simple_tool_3,
simple_tool_4,
simple_tool_5,
structured_tool_1,
structured_tool_2,
structured_tool_3,
structured_tool_4,
deep_nested_tool,
complex_tool_1,
complex_tool_2,
complex_tool_3,
complex_tool_4,
complex_tool_5,
]
def _make_model() -> GenericFakeChatModel:
return GenericFakeChatModel(messages=cycle([AIMessage(content="ok")]))
# ---------------------------------------------------------------------------
# Benchmarks
# ---------------------------------------------------------------------------
@pytest.mark.benchmark
def test_create_agent_instantiation(benchmark: BenchmarkFixture) -> None:
"""Baseline: no tools."""
benchmark(lambda: create_agent(model=_make_model()))
def instantiate_agent() -> None:
create_agent(model=GenericFakeChatModel(messages=iter([AIMessage(content="ok")])))
benchmark(instantiate_agent)
@pytest.mark.benchmark
def test_create_agent_small_tools(benchmark: BenchmarkFixture) -> None:
"""3 simple tools."""
benchmark(lambda: create_agent(model=_make_model(), tools=SMALL_TOOLS))
@pytest.mark.benchmark
def test_create_agent_medium_tools(benchmark: BenchmarkFixture) -> None:
"""7 mixed tools."""
benchmark(lambda: create_agent(model=_make_model(), tools=MEDIUM_TOOLS))
@pytest.mark.benchmark
def test_create_agent_large_tools(benchmark: BenchmarkFixture) -> None:
"""15 tools including complex nested schemas."""
benchmark(lambda: create_agent(model=_make_model(), tools=LARGE_TOOLS))
@pytest.mark.benchmark
def test_create_agent_large_tools_with_middleware(benchmark: BenchmarkFixture) -> None:
"""15 tools + full middleware stack."""
def run() -> None:
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=_make_model(),
tools=LARGE_TOOLS,
model=GenericFakeChatModel(messages=iter([AIMessage(content="ok")])),
middleware=middleware,
)
benchmark(run)
@pytest.mark.benchmark
def test_tool_call_schema_repeated_access(benchmark: BenchmarkFixture) -> None:
"""Measure cost of repeated .tool_call_schema access on a complex tool (10 accesses per iteration)."""
t = structured_tool_1
def access_schema_10x() -> None:
for _ in range(10):
t.tool_call_schema
benchmark(access_schema_10x)
@pytest.mark.benchmark
def test_tool_args_repeated_access(benchmark: BenchmarkFixture) -> None:
"""Measure cost of repeated .args access on a complex tool (10 accesses per iteration)."""
t = structured_tool_1
def access_args_10x() -> None:
for _ in range(10):
t.args
benchmark(access_args_10x)
@pytest.mark.benchmark
def test_create_agent_instantiation_with_middleware(benchmark: BenchmarkFixture) -> None:
"""Baseline with middleware, no tools."""
def run() -> None:
middleware: Sequence[AgentMiddleware[Any, Any]] = (
TodoListMiddleware(),
ToolRetryMiddleware(),
ModelRetryMiddleware(),
)
create_agent(model=_make_model(), middleware=middleware)
benchmark(run)
# ---------------------------------------------------------------------------
# Memory snapshot (not a codspeed benchmark — uses tracemalloc directly)
# ---------------------------------------------------------------------------
@pytest.mark.benchmark
def test_create_agent_large_tools_memory() -> None:
"""Observe peak memory for large-tools agent creation.
This is not a hard assertion — it records the tracemalloc peak for the
memory allocated *during* create_agent. Run before and after optimization
passes to track improvement. Update the printed baseline comment below
when the number changes significantly.
"""
tracemalloc.start()
create_agent(model=_make_model(), tools=LARGE_TOOLS)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
peak_kb = peak / 1024
# Baseline (pre-optimization): ~recorded after first run
print(f"\nPeak memory during create_agent (15 tools): {peak_kb:.1f} KB")
benchmark(instantiate_agent)

View File

@@ -29,11 +29,19 @@ from langsmith import Client
from langsmith.run_helpers import tracing_context
from langchain.agents import create_agent
from langchain.agents.middleware.types import AgentMiddleware, AgentState
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
ModelCallResult,
ModelRequest,
ModelResponse,
)
from langchain.tools import InjectedState, ToolRuntime
from tests.unit_tests.agents.model import FakeToolCallingModel
if TYPE_CHECKING:
from collections.abc import Callable
from langgraph.runtime import Runtime
@@ -849,107 +857,170 @@ async def test_combined_injected_state_runtime_store_async() -> None:
assert injected_data["store_write_success"] is True
def test_ls_agent_type_is_trace_only_metadata() -> None:
"""Test that ls_agent_type is added to metadata on tracing only, not in streamed chunks."""
# Capture metadata from regular callback handler (simulates streamed metadata)
captured_callback_metadata: list[dict[str, Any]] = []
# ---------------------------------------------------------------------------
# ls_agent_type tracing metadata
# ---------------------------------------------------------------------------
class CaptureHandler(BaseCallbackHandler):
def on_chain_start(
class _CaptureCallbackHandler(BaseCallbackHandler):
"""Records metadata observed on every ``on_chain_start`` callback."""
def __init__(self) -> None:
self.captured: list[dict[str, Any]] = []
def on_chain_start(
self,
serialized: dict[str, Any],
inputs: dict[str, Any],
*,
run_id: str,
parent_run_id: str | None = None,
tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
self.captured.append(
{
"name": kwargs.get("name") or (serialized or {}).get("name"),
"tags": tags,
"metadata": metadata or {},
}
)
def _build_mock_langsmith_client() -> tuple[MagicMock, Client]:
"""Return a (session, client) pair where the session records tracing POSTs."""
mock_session = MagicMock()
mock_client = Client(session=mock_session, api_key="test", auto_batch_tracing=False)
return mock_session, mock_client
def _posted_runs(mock_session: MagicMock) -> list[dict[str, Any]]:
"""Extract the run dicts POSTed to the LangSmith API by the mock session."""
posts: list[dict[str, Any]] = []
for call in mock_session.request.mock_calls:
if call.args and call.args[0] == "POST":
body = json.loads(call.kwargs["data"])
if "post" in body:
posts.extend(body["post"])
else:
posts.append(body)
return posts
def _run_metadata(post: dict[str, Any]) -> dict[str, Any]:
return post.get("extra", {}).get("metadata", {}) or {}
def test_ls_agent_type_root_is_trace_only_metadata() -> None:
"""``ls_agent_type='root'`` reaches the LangSmith tracer but not callback metadata."""
handler = _CaptureCallbackHandler()
mock_session, mock_client = _build_mock_langsmith_client()
agent = create_agent(
model=FakeToolCallingModel(tool_calls=[[], []]),
tools=[],
system_prompt="You are a helpful assistant.",
)
with tracing_context(client=mock_client, enabled=True):
agent.invoke(
{"messages": [HumanMessage("hi?")]},
config={"callbacks": [handler]},
)
# ls_agent_type must not leak into callback metadata.
assert handler.captured, "expected on_chain_start to fire at least once"
for entry in handler.captured:
assert entry["metadata"].get("ls_agent_type") is None, (
f"ls_agent_type leaked into callback metadata: {entry['metadata']}"
)
# ls_agent_type='root' must reach the tracer on the root run.
posts = _posted_runs(mock_session)
assert posts, "expected at least one LangSmith POST"
assert _run_metadata(posts[0]).get("ls_agent_type") == "root"
def test_ls_agent_type_is_overridable_via_configurable() -> None:
"""A caller can override ``ls_agent_type`` (and add keys) via ``configurable``."""
mock_session, mock_client = _build_mock_langsmith_client()
agent = create_agent(
model=FakeToolCallingModel(tool_calls=[[], []]),
tools=[],
system_prompt="You are a helpful assistant.",
)
with tracing_context(client=mock_client, enabled=True):
agent.invoke(
{"messages": [HumanMessage("hi?")]},
config={
"configurable": {
"ls_agent_type": "subagent",
"custom_key": "custom_value",
}
},
)
posts = _posted_runs(mock_session)
assert posts, "expected at least one LangSmith POST"
root_metadata = _run_metadata(posts[0])
assert root_metadata.get("ls_agent_type") == "subagent"
# Extra configurable keys also flow into tracer metadata.
assert root_metadata.get("custom_key") == "custom_value"
def test_ls_agent_type_middleware_is_trace_only_metadata() -> None:
"""Middleware traceable runs are tagged with ``ls_agent_type='middleware'``.
The tag is attached via the ``metadata=`` argument of langsmith's
``traceable`` decorator, which routes it to ``run.extra.metadata`` for
LangSmith only -- it must not leak into on_chain_start callback metadata.
"""
class PassthroughMiddleware(AgentMiddleware):
name = "test-passthrough"
def wrap_model_call(
self,
serialized: dict[str, Any],
inputs: dict[str, Any],
*,
run_id: str,
parent_run_id: str | None = None,
tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
captured_callback_metadata.append({"tags": tags, "metadata": metadata})
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
return handler(request)
# Create a mock client to capture what gets sent to LangSmith
mock_session = MagicMock()
mock_client = Client(session=mock_session, api_key="test", auto_batch_tracing=False)
handler = _CaptureCallbackHandler()
mock_session, mock_client = _build_mock_langsmith_client()
agent = create_agent(
model=FakeToolCallingModel(tool_calls=[[], []]),
tools=[],
system_prompt="You are a helpful assistant.",
middleware=[PassthroughMiddleware()],
)
# Use tracing_context to enable tracing with the mock client
with tracing_context(client=mock_client, enabled=True):
agent.invoke(
{"messages": [HumanMessage("hi?")]},
config={"callbacks": [CaptureHandler()]},
config={"callbacks": [handler]},
)
# Verify that ls_agent_type is NOT in the regular callback metadata
# (it should only go to the tracer via langsmith_inheritable_metadata)
assert len(captured_callback_metadata) > 0
for captured in captured_callback_metadata:
metadata = captured.get("metadata") or {}
assert metadata.get("ls_agent_type") is None, (
f"ls_agent_type should not be in callback metadata, but got: {metadata}"
# (1) ls_agent_type='middleware' must not leak into callback metadata.
for entry in handler.captured:
assert entry["metadata"].get("ls_agent_type") != "middleware", (
f"ls_agent_type='middleware' leaked into callback metadata for "
f"run {entry['name']!r}: {entry['metadata']}"
)
# Verify that ls_agent_type IS in the tracer metadata (sent to LangSmith)
# Get the POST requests to the LangSmith API
posts = []
for call in mock_session.request.mock_calls:
if call.args and call.args[0] == "POST":
body = json.loads(call.kwargs["data"])
if "post" in body:
posts.extend(body["post"])
else:
posts.append(body)
assert len(posts) >= 1
# Find the root run (the agent execution)
root_post = posts[0]
metadata = root_post.get("extra", {}).get("metadata", {})
assert metadata.get("ls_agent_type") == "root", (
f"ls_agent_type should be 'root' in tracer metadata, but got: {metadata}"
# (2) ls_agent_type='middleware' must reach the LangSmith tracer, on a run
# named after the middleware's traceable (e.g. 'test-passthrough.wrap_model_call').
posts = _posted_runs(mock_session)
middleware_posts = [p for p in posts if _run_metadata(p).get("ls_agent_type") == "middleware"]
assert middleware_posts, (
f"expected a LangSmith post with ls_agent_type='middleware'; "
f"saw metadatas: {[_run_metadata(p) for p in posts]}"
)
def test_ls_agent_type_is_overridable() -> None:
"""Test that ls_agent_type can be overridden via configurable in invoke config."""
# Create a mock client to capture what gets sent to LangSmith
mock_session = MagicMock()
mock_client = Client(session=mock_session, api_key="test", auto_batch_tracing=False)
agent = create_agent(
model=FakeToolCallingModel(tool_calls=[[], []]),
tools=[],
system_prompt="You are a helpful assistant.",
)
# Use tracing_context to enable tracing with the mock client
with tracing_context(client=mock_client, enabled=True):
agent.invoke(
{"messages": [HumanMessage("hi?")]},
config={"configurable": {"ls_agent_type": "subagent", "custom_key": "custom_value"}},
)
# Verify that ls_agent_type is overridden and configurable is merged in the tracer metadata
posts = []
for call in mock_session.request.mock_calls:
if call.args and call.args[0] == "POST":
body = json.loads(call.kwargs["data"])
if "post" in body:
posts.extend(body["post"])
else:
posts.append(body)
assert len(posts) >= 1
root_post = posts[0]
metadata = root_post.get("extra", {}).get("metadata", {})
assert metadata.get("ls_agent_type") == "subagent", (
f"ls_agent_type should be 'subagent' in tracer metadata, but got: {metadata}"
)
# Verify that the additional configurable key is merged into metadata
assert metadata.get("custom_key") == "custom_value", (
f"custom_key should be 'custom_value' in tracer metadata, but got: {metadata}"
assert any("test-passthrough" in (p.get("name") or "") for p in middleware_posts), (
f"expected a middleware run named like 'test-passthrough.wrap_model_call', "
f"got: {[p.get('name') for p in middleware_posts]}"
)

View File

@@ -1417,7 +1417,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" },
{ url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" },
{ url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" },
{ url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" },
{ url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" },
@@ -1428,7 +1427,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
{ url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" },
{ url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" },
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
@@ -1439,7 +1437,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
{ url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" },
{ url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" },
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
@@ -1450,7 +1447,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
@@ -1461,7 +1457,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
@@ -1989,7 +1984,6 @@ test = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-benchmark" },
{ name = "pytest-codspeed" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
{ name = "pytest-socket" },
@@ -2045,7 +2039,6 @@ test = [
{ name = "pytest", specifier = ">=9.0.3,<10.0.0" },
{ name = "pytest-asyncio", specifier = ">=1.3.0,<2.0.0" },
{ name = "pytest-benchmark", specifier = ">=5.1.0,<6.0.0" },
{ name = "pytest-codspeed", specifier = ">=3.0.0,<4.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" },
@@ -4172,24 +4165,28 @@ wheels = [
[[package]]
name = "pytest-codspeed"
version = "3.2.0"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
{ name = "pytest" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/98/16fe3895b1b8a6d537a89eecb120b97358df8f0002c6ecd11555d6304dc8/pytest_codspeed-3.2.0.tar.gz", hash = "sha256:f9d1b1a3b2c69cdc0490a1e8b1ced44bffbd0e8e21d81a7160cfdd923f6e8155", size = 18409, upload-time = "2025-01-31T14:28:26.165Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/e8/27fcbe6516a1c956614a4b61a7fccbf3791ea0b992e07416e8948184327d/pytest_codspeed-4.2.0.tar.gz", hash = "sha256:04b5d0bc5a1851ba1504d46bf9d7dbb355222a69f2cd440d54295db721b331f7", size = 113263, upload-time = "2025-10-24T09:02:55.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/31/62b93ee025ca46016d01325f58997d32303752286bf929588c8796a25b13/pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34", size = 26802, upload-time = "2025-01-31T14:28:10.723Z" },
{ url = "https://files.pythonhosted.org/packages/89/60/2bc46bdf8c8ddb7e59cd9d480dc887d0ac6039f88c856d1ae3d29a4e648d/pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c", size = 25442, upload-time = "2025-01-31T14:28:11.774Z" },
{ url = "https://files.pythonhosted.org/packages/31/56/1b65ba0ae1af7fd7ce14a66e7599833efe8bbd0fcecd3614db0017ca224a/pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf6f56067538f4892baa8d7ab5ef4e45bb59033be1ef18759a2c7fc55b32035", size = 26810, upload-time = "2025-01-31T14:28:12.657Z" },
{ url = "https://files.pythonhosted.org/packages/23/e6/d1fafb09a1c4983372f562d9e158735229cb0b11603a61d4fad05463f977/pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a687b05c3d145642061b45ea78e47e12f13ce510104d1a2cda00eee0e36f58", size = 25442, upload-time = "2025-01-31T14:28:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/0b/8b/9e95472589d17bb68960f2a09cfa8f02c4d43c82de55b73302bbe0fa4350/pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46a1afaaa1ac4c2ca5b0700d31ac46d80a27612961d031067d73c6ccbd8d3c2b", size = 27182, upload-time = "2025-01-31T14:28:15.828Z" },
{ url = "https://files.pythonhosted.org/packages/2a/18/82aaed8095e84d829f30dda3ac49fce4e69685d769aae463614a8d864cdd/pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48ce3af3dfa78413ed3d69d1924043aa1519048dbff46edccf8f35a25dab3c2", size = 25933, upload-time = "2025-01-31T14:28:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/e2/15/60b18d40da66e7aa2ce4c4c66d5a17de20a2ae4a89ac09a58baa7a5bc535/pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66692506d33453df48b36a84703448cb8b22953eea51f03fbb2eb758dc2bdc4f", size = 27180, upload-time = "2025-01-31T14:28:18.056Z" },
{ url = "https://files.pythonhosted.org/packages/51/bd/6b164d4ae07d8bea5d02ad664a9762bdb63f83c0805a3c8fe7dc6ec38407/pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:479774f80d0bdfafa16112700df4dbd31bf2a6757fac74795fd79c0a7b3c389b", size = 25923, upload-time = "2025-01-31T14:28:19.725Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9b/952c70bd1fae9baa58077272e7f191f377c86d812263c21b361195e125e6/pytest_codspeed-3.2.0-py3-none-any.whl", hash = "sha256:54b5c2e986d6a28e7b0af11d610ea57bd5531cec8326abe486f1b55b09d91c39", size = 15007, upload-time = "2025-01-31T14:28:24.458Z" },
{ url = "https://files.pythonhosted.org/packages/6c/b8/d599a466c50af3f04001877ae8b17c12b803f3b358235736b91a0769de0d/pytest_codspeed-4.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609828b03972966b75b9b7416fa2570c4a0f6124f67e02d35cd3658e64312a7b", size = 261943, upload-time = "2025-10-24T09:02:37.962Z" },
{ url = "https://files.pythonhosted.org/packages/74/19/ccc1a2fcd28357a8db08ba6b60f381832088a3850abc262c8e0b3406491a/pytest_codspeed-4.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23a0c0fbf8bb4de93a3454fd9e5efcdca164c778aaef0a9da4f233d85cb7f5b8", size = 250782, upload-time = "2025-10-24T09:02:39.617Z" },
{ url = "https://files.pythonhosted.org/packages/b9/2d/f0083a2f14ecf008d961d40439a71da0ae0d568e5f8dc2fccd3e8a2ab3e4/pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2de87bde9fbc6fd53f0fd21dcf2599c89e0b8948d49f9bad224edce51c47e26b", size = 261960, upload-time = "2025-10-24T09:02:40.665Z" },
{ url = "https://files.pythonhosted.org/packages/5f/0c/1f514c553db4ea5a69dfbe2706734129acd0eca8d5101ec16f1dd00dbc0f/pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95aeb2479ca383f6b18e2cc9ebcd3b03ab184980a59a232aea6f370bbf59a1e3", size = 250808, upload-time = "2025-10-24T09:02:42.07Z" },
{ url = "https://files.pythonhosted.org/packages/81/04/479905bd6653bc981c0554fcce6df52d7ae1594e1eefd53e6cf31810ec7f/pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d4fefbd4ae401e2c60f6be920a0be50eef0c3e4a1f0a1c83962efd45be38b39", size = 262084, upload-time = "2025-10-24T09:02:43.155Z" },
{ url = "https://files.pythonhosted.org/packages/d2/46/d6f345d7907bac6cbb6224bd697ecbc11cf7427acc9e843c3618f19e3476/pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309b4227f57fcbb9df21e889ea1ae191d0d1cd8b903b698fdb9ea0461dbf1dfe", size = 251100, upload-time = "2025-10-24T09:02:44.168Z" },
{ url = "https://files.pythonhosted.org/packages/de/dc/e864f45e994a50390ff49792256f1bdcbf42f170e3bc0470ee1a7d2403f3/pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72aab8278452a6d020798b9e4f82780966adb00f80d27a25d1274272c54630d5", size = 262057, upload-time = "2025-10-24T09:02:45.791Z" },
{ url = "https://files.pythonhosted.org/packages/1d/1c/f1d2599784486879cf6579d8d94a3e22108f0e1f130033dab8feefd29249/pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:684fcd9491d810ded653a8d38de4835daa2d001645f4a23942862950664273f8", size = 251013, upload-time = "2025-10-24T09:02:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/0c/fd/eafd24db5652a94b4d00fe9b309b607de81add0f55f073afb68a378a24b6/pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50794dabea6ec90d4288904452051e2febace93e7edf4ca9f2bce8019dd8cd37", size = 262065, upload-time = "2025-10-24T09:02:48.018Z" },
{ url = "https://files.pythonhosted.org/packages/f9/14/8d9340d7dc0ae647991b28a396e16b3403e10def883cde90d6b663d3f7ec/pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0ebd87f2a99467a1cfd8e83492c4712976e43d353ee0b5f71cbb057f1393aca", size = 251057, upload-time = "2025-10-24T09:02:49.102Z" },
{ url = "https://files.pythonhosted.org/packages/4b/39/48cf6afbca55bc7c8c93c3d4ae926a1068bcce3f0241709db19b078d5418/pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbbb2d61b85bef8fc7e2193f723f9ac2db388a48259d981bbce96319043e9830", size = 267983, upload-time = "2025-10-24T09:02:50.558Z" },
{ url = "https://files.pythonhosted.org/packages/33/86/4407341efb5dceb3e389635749ce1d670542d6ca148bd34f9d5334295faf/pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748411c832147bfc85f805af78a1ab1684f52d08e14aabe22932bbe46c079a5f", size = 256732, upload-time = "2025-10-24T09:02:51.603Z" },
{ url = "https://files.pythonhosted.org/packages/25/0e/8cb71fd3ed4ed08c07aec1245aea7bc1b661ba55fd9c392db76f1978d453/pytest_codspeed-4.2.0-py3-none-any.whl", hash = "sha256:e81bbb45c130874ef99aca97929d72682733527a49f84239ba575b5cb843bab0", size = 113726, upload-time = "2025-10-24T09:02:54.785Z" },
]
[[package]]

View File

@@ -705,10 +705,6 @@ class AzureChatOpenAI(BaseChatOpenAI):
return self
def _resolve_model_profile(self) -> ModelProfile | None:
if (self.model_name is not None) and (
profile := _get_default_model_profile(self.model_name) or None
):
return profile
if self.deployment_name is not None:
return _get_default_model_profile(self.deployment_name) or None
return None

View File

@@ -41,6 +41,31 @@ _PROFILES: dict[str, dict[str, Any]] = {
"image_tool_message": True,
"tool_choice": True,
},
"codex-mini-latest": {
"name": "Codex Mini",
"release_date": "2025-05-16",
"last_updated": "2025-05-16",
"open_weights": False,
"max_input_tokens": 200000,
"max_output_tokens": 100000,
"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": True,
"temperature": False,
"image_url_inputs": True,
"pdf_inputs": True,
"pdf_tool_message": True,
"image_tool_message": True,
"tool_choice": True,
},
"gpt-3.5-turbo": {
"name": "GPT-3.5-turbo",
"release_date": "2023-03-01",

View File

@@ -16,7 +16,6 @@ def test_initialize_azure_openai() -> None:
azure_deployment="35-turbo-dev",
openai_api_version="2023-05-15",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
)
assert llm.deployment_name == "35-turbo-dev"
assert llm.openai_api_version == "2023-05-15"
@@ -46,92 +45,6 @@ def test_initialize_more() -> None:
assert ls_params.get("ls_model_name") == "gpt-35-turbo-0125"
def test_profile_resolves_from_model_name() -> None:
llm = AzureChatOpenAI(
model="gpt-4o",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
api_version="2023-05-15",
)
assert llm.profile
assert llm.profile["name"] == "GPT-4o"
assert llm.profile["max_input_tokens"] == 128_000
def test_profile_resolves_from_model_name_with_custom_deployment_alias() -> None:
llm = AzureChatOpenAI(
model="gpt-4o",
azure_deployment="35-turbo-dev",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
api_version="2023-05-15",
)
assert llm.profile
assert llm.profile["name"] == "GPT-4o"
def test_profile_prefers_model_name_over_known_deployment_name() -> None:
llm = AzureChatOpenAI(
model="gpt-4o",
azure_deployment="gpt-4",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
api_version="2023-05-15",
)
assert llm.profile
assert llm.profile["name"] == "GPT-4o"
def test_profile_falls_back_to_deployment_name_with_unknown_model() -> None:
llm = AzureChatOpenAI(
model="unknown-model",
azure_deployment="gpt-4o",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
api_version="2023-05-15",
)
assert llm.profile
def test_profile_resolves_from_deployment_name_without_model() -> None:
llm = AzureChatOpenAI(
azure_deployment="gpt-4o",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
api_version="2023-05-15",
)
assert llm.profile
assert llm.profile["name"] == "GPT-4o"
def test_profile_respects_explicit_profile() -> None:
llm = AzureChatOpenAI(
model="gpt-4o",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
api_version="2023-05-15",
profile={"tool_calling": False},
)
assert llm.profile == {"tool_calling": False}
def test_profile_is_none_for_unknown_deployment_without_model() -> None:
llm = AzureChatOpenAI(
azure_deployment="unknown-deployment",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
api_version="2023-05-15",
)
assert llm.profile is None
def test_initialize_azure_openai_with_openai_api_base_set() -> None:
with mock.patch.dict(os.environ, {"OPENAI_API_BASE": "https://api.openai.com"}):
llm = AzureChatOpenAI( # type: ignore[call-arg, call-arg]
@@ -166,7 +79,6 @@ def test_structured_output_old_model() -> None:
azure_deployment="35-turbo-dev",
openai_api_version="2023-05-15",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
).with_structured_output(Output)
# assert tool calling was used instead of json_schema
@@ -179,7 +91,6 @@ def test_max_completion_tokens_in_payload() -> None:
azure_deployment="o1-mini",
api_version="2024-12-01-preview",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
model_kwargs={"max_completion_tokens": 300},
)
messages = [HumanMessage("Hello")]
@@ -237,7 +148,6 @@ def test_max_completion_tokens_parameter() -> None:
azure_deployment="gpt-5",
api_version="2024-12-01-preview",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
max_completion_tokens=1500,
)
messages = [HumanMessage("Hello")]
@@ -255,7 +165,6 @@ def test_max_tokens_converted_to_max_completion_tokens() -> None:
azure_deployment="gpt-5",
api_version="2024-12-01-preview",
azure_endpoint="my-base-url",
api_key=SecretStr("test"),
max_tokens=1000, # type: ignore[call-arg]
)
messages = [HumanMessage("Hello")]

View File

@@ -167,28 +167,6 @@ _PROFILES: dict[str, dict[str, Any]] = {
"attachment": True,
"temperature": True,
},
"anthropic/claude-opus-4.7": {
"name": "Claude Opus 4.7",
"release_date": "2026-04-16",
"last_updated": "2026-04-16",
"open_weights": False,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"pdf_inputs": True,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": False,
},
"anthropic/claude-sonnet-4": {
"name": "Claude Sonnet 4",
"release_date": "2025-05-22",