Compare commits

..

10 Commits

Author SHA1 Message Date
Sydney Runkle
0545feead7 perf(core): defer tracer imports in runnables/base.py to call time
event_stream, log_stream, and root_listeners are only needed by astream_log
and astream_events. Moving these imports inside those methods eliminates ~90ms
from 'from langchain_core.language_models import BaseChatModel' by breaking
the eager load chain into langsmith.schemas.
2026-04-21 10:58:56 -04:00
Sydney Runkle
e872675c28 chore: ignore .worktrees/ directory 2026-04-21 10:54:42 -04:00
Sydney Runkle
3a36d4cd2f perf(core): cache _create_subset_model results with lru_cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:01:39 -04:00
Sydney Runkle
0f042990e3 perf(core): invalidate cached tool_call_schema and args on field mutation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:57:07 -04:00
Sydney Runkle
264a82c6ef perf(core): avoid repeated tool_call_schema access in _format_tool_to_openai_function 2026-04-21 09:45:16 -04:00
Sydney Runkle
ce04a922e3 perf(core): cache BaseTool.tool_call_schema and args as cached_property
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:42:20 -04:00
Sydney Runkle
50b7456d80 test(langchain): fix benchmark quality issues from code review
- Move middleware construction inside benchmarked lambdas for fresh instances
- Rework memory test to observation-only with print output (no hard assertion)
- Add deeply-nested Pydantic schema tool (RouteSchema) to LARGE_TOOLS (15 tools)
- Update docstrings to document '10 accesses per iteration' in schema benchmarks
- Fix bare `_ =` pattern in schema benchmarks (bare expressions)
- Mark memory test with @pytest.mark.benchmark to exclude from normal runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:29:36 -04:00
Sydney Runkle
9ab81a0908 test(langchain): expand create_agent benchmarks with tool-heavy scenarios
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:25:38 -04:00
Sydney Runkle
7b7aced419 chore(langchain): add pytest-codspeed to test dependencies 2026-04-21 08:21:38 -04:00
Thomas
8fec4e7cee fix(openai): infer azure chat profiles from model name (#36858) 2026-04-19 11:06:26 -04:00
17 changed files with 539 additions and 348 deletions

1
.gitignore vendored
View File

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

View File

@@ -82,18 +82,6 @@ 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
@@ -111,8 +99,21 @@ 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.log_stream import RunLog, RunLogPatch
from langchain_core.tracers.root_listeners import AsyncListener
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.schemas import Run
@@ -1245,6 +1246,11 @@ 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,
@@ -1480,6 +1486,11 @@ 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,
@@ -1722,6 +1733,10 @@ 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=[
@@ -1819,6 +1834,10 @@ 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=[
@@ -6049,6 +6068,9 @@ 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

@@ -7,15 +7,7 @@ import asyncio
# Cannot move uuid to TYPE_CHECKING as RunnableConfig is used in Pydantic models
import uuid # noqa: TC003
import warnings
from collections.abc import (
Awaitable,
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
)
from collections.abc import Awaitable, Callable, Generator, Iterable, Iterator, Sequence
from concurrent.futures import Executor, Future, ThreadPoolExecutor
from contextlib import contextmanager
from contextvars import Context, ContextVar, Token, copy_context
@@ -37,7 +29,6 @@ from langchain_core.runnables.utils import (
accepts_config,
accepts_run_manager,
)
from langchain_core.tracers._constants import LANGSMITH_INHERITABLE_METADATA_KEYS
if TYPE_CHECKING:
from langchain_core.callbacks.base import BaseCallbackManager, Callbacks
@@ -155,30 +146,17 @@ CONFIGURABLE_TO_TRACING_METADATA_EXCLUDED_KEYS = frozenset(("api_key",))
def _get_langsmith_inheritable_metadata_from_config(
config: RunnableConfig,
*,
extra_metadata: Mapping[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Get LangSmith-only inheritable metadata defaults derived from config.
Args:
config: The config.
extra_metadata: Additional LangSmith-only metadata to merge in (takes
precedence over configurable-derived entries). Intended for entries
already extracted from ``config["metadata"]`` by the caller so we
don't iterate over it twice.
"""
"""Get LangSmith-only inheritable metadata defaults derived from config."""
configurable = config.get("configurable") or {}
config_metadata = config.get("metadata") 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_metadata
and key not in config.get("metadata", {})
and key not in CONFIGURABLE_TO_TRACING_METADATA_EXCLUDED_KEYS
}
if extra_metadata:
metadata.update(extra_metadata)
return metadata or None
@@ -542,36 +520,6 @@ def acall_func_with_variable_args(
return func(input, **kwargs) # type: ignore[call-arg]
def _split_inheritable_metadata(
config: RunnableConfig,
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
"""Split ``config["metadata"]`` into general and LangSmith-only halves.
Allowlisted keys (see
:data:`langchain_core.tracers._constants.LANGSMITH_INHERITABLE_METADATA_KEYS`)
are *moved* out of the general inheritable metadata so they don't reach
non-tracer callback handlers (e.g. ``stream_events`` output). Nested
override semantics are preserved on the tracer side by
``LangChainTracer.copy_with_metadata_defaults``, which treats the same
allowlist as last-wins.
Returns a tuple of ``(general_metadata, langsmith_only_metadata)``. The
first element preserves ``None`` when the config has no ``metadata`` key
so callers can forward it unchanged to ``CallbackManager.configure``.
"""
metadata = config.get("metadata")
if metadata is None:
return None, {}
general: dict[str, Any] = {}
langsmith_only: dict[str, Any] = {}
for key, value in metadata.items():
if key in LANGSMITH_INHERITABLE_METADATA_KEYS:
langsmith_only[key] = value
else:
general[key] = value
return general, langsmith_only
def get_callback_manager_for_config(config: RunnableConfig) -> CallbackManager:
"""Get a callback manager for a config.
@@ -581,13 +529,12 @@ def get_callback_manager_for_config(config: RunnableConfig) -> CallbackManager:
Returns:
The callback manager.
"""
general_metadata, langsmith_only_metadata = _split_inheritable_metadata(config)
return CallbackManager.configure(
inheritable_callbacks=config.get("callbacks"),
inheritable_tags=config.get("tags"),
inheritable_metadata=general_metadata,
inheritable_metadata=config.get("metadata"),
langsmith_inheritable_metadata=_get_langsmith_inheritable_metadata_from_config(
config, extra_metadata=langsmith_only_metadata
config
),
)
@@ -603,13 +550,12 @@ def get_async_callback_manager_for_config(
Returns:
The async callback manager.
"""
general_metadata, langsmith_only_metadata = _split_inheritable_metadata(config)
return AsyncCallbackManager.configure(
inheritable_callbacks=config.get("callbacks"),
inheritable_tags=config.get("tags"),
inheritable_metadata=general_metadata,
inheritable_metadata=config.get("metadata"),
langsmith_inheritable_metadata=_get_langsmith_inheritable_metadata_from_config(
config, extra_metadata=langsmith_only_metadata
config
),
)

View File

@@ -552,8 +552,15 @@ 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.
@@ -564,7 +571,7 @@ class ChildTool(BaseTool):
keys = {k for k in self.args if k != "kwargs"}
return len(keys) == 1
@property
@functools.cached_property
def args(self) -> dict:
"""Get the tool's input arguments schema.
@@ -583,7 +590,7 @@ class ChildTool(BaseTool):
json_schema = input_schema.model_json_schema()
return cast("dict", json_schema["properties"])
@property
@functools.cached_property
def tool_call_schema(self) -> ArgsSchema:
"""Get the schema for tool calls, excluding injected arguments.

View File

@@ -1,25 +0,0 @@
"""Private constants shared across tracer and runnable-config code.
These live in their own module so they can be imported without pulling in
the heavier ``langchain_core.tracers.langchain`` dependency (which loads
``langsmith`` transitively).
"""
from __future__ import annotations
LANGSMITH_INHERITABLE_METADATA_KEYS: frozenset[str] = frozenset(("ls_agent_type",))
"""Allowlist of metadata keys routed to LangSmith tracers only.
Keys in this set are:
1. Stripped from general ``inheritable_metadata`` by
``langchain_core.runnables.config._split_inheritable_metadata`` so they
don't reach non-tracer callback handlers (``stream_events``,
``astream_log``, user-provided ``BaseCallbackHandler`` instances, etc.).
2. Forwarded to ``LangChainTracer`` as *overridable* defaults via
``LangChainTracer.copy_with_metadata_defaults``. Unlike general
metadata defaults (first-wins), keys in this allowlist are last-wins so
that a nested ``RunnableConfig`` / ``CallbackManager.configure`` call
can rescope the value to the innermost run (e.g. ``ls_agent_type``).
"""
# TODO: Expand this to cover all `ls_`-prefixed metadata keys.

View File

@@ -23,7 +23,6 @@ from langchain_core.env import get_runtime_environment
from langchain_core.load import dumpd
from langchain_core.messages.ai import UsageMetadata, add_usage
from langchain_core.tracers._compat import run_construct, run_to_dict
from langchain_core.tracers._constants import LANGSMITH_INHERITABLE_METADATA_KEYS
from langchain_core.tracers.base import BaseTracer
from langchain_core.tracers.schemas import Run
@@ -168,16 +167,7 @@ class LangChainTracer(BaseTracer):
metadata: Mapping[str, str] | None = None,
tags: list[str] | None = None,
) -> LangChainTracer:
"""Return a new tracer with merged tracer-only defaults.
By default, keys already present on this tracer take precedence over
``metadata`` (first-wins). Keys in
:data:`langchain_core.tracers._constants.LANGSMITH_INHERITABLE_METADATA_KEYS`
are the exception: they are treated as narrowly scoped, overridable
defaults so that a nested ``RunnableConfig`` /
``CallbackManager.configure`` call can rescope them to the innermost
run (e.g. ``ls_agent_type``).
"""
"""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
@@ -186,9 +176,7 @@ class LangChainTracer(BaseTracer):
else:
merged_metadata = dict(base_metadata)
for key, value in metadata.items():
if key in LANGSMITH_INHERITABLE_METADATA_KEYS or (
key not in merged_metadata
):
if key not in merged_metadata:
merged_metadata[key] = value
merged_tags = sorted(set(self.tags + tags)) if tags else self.tags

View File

@@ -340,17 +340,18 @@ 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
)
if tool.tool_call_schema and not is_simple_oai_tool:
if isinstance(tool.tool_call_schema, dict):
schema = tool.tool_call_schema
if schema and not is_simple_oai_tool:
if isinstance(schema, dict):
return _convert_json_schema_to_openai_function(
tool.tool_call_schema, name=tool.name, description=tool.description
schema, name=tool.name, description=tool.description
)
if issubclass(tool.tool_call_schema, (BaseModel, BaseModelV1)):
if issubclass(schema, (BaseModel, BaseModelV1)):
return _convert_pydantic_to_openai_function(
tool.tool_call_schema, name=tool.name, description=tool.description
schema, name=tool.name, description=tool.description
)
error_msg = (
f"Unsupported tool call schema: {tool.tool_call_schema}. "
f"Unsupported tool call schema: {schema}. "
"Tool call schema must be a JSON schema dict or a Pydantic model."
)
raise ValueError(error_msg)

View File

@@ -199,18 +199,21 @@ class _IgnoreUnserializable(GenerateJsonSchema):
return {}
@lru_cache(maxsize=256)
def _create_subset_model_v1(
name: str,
model: type[BaseModelV1],
field_names: list,
field_names: tuple[str, ...],
*,
descriptions: dict | None = None,
descriptions: tuple[tuple[str, str], ...] | 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:
for field_name in field_names_list:
# Using pydantic v1 so can access __fields__ as a dict.
field = model.__fields__[field_name]
t = (
@@ -219,8 +222,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]
@@ -228,18 +231,20 @@ def _create_subset_model_v1(
return rtn
@lru_cache(maxsize=256)
def _create_subset_model_v2(
name: str,
model: type[BaseModel],
field_names: list[str],
field_names: tuple[str, ...],
*,
descriptions: dict | None = None,
descriptions: tuple[tuple[str, str], ...] | None = None,
fn_description: str | None = None,
) -> type[BaseModel]:
"""Create a Pydantic model with a subset of the model fields."""
descriptions_ = descriptions or {}
descriptions_ = dict(descriptions) if descriptions else {}
field_names_list = list(field_names)
fields = {}
for field_name in field_names:
for field_name in field_names_list:
field = model.model_fields[field_name]
description = descriptions_.get(field_name, field.description)
field_kwargs: dict[str, Any] = {"description": description}
@@ -291,19 +296,20 @@ 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,
field_names,
descriptions=descriptions,
tuple(field_names),
descriptions=descriptions_tuple,
fn_description=fn_description,
)
return _create_subset_model_v2(
name,
model,
field_names,
descriptions=descriptions,
tuple(field_names),
descriptions=descriptions_tuple,
fn_description=fn_description,
)

View File

@@ -19,8 +19,6 @@ from langchain_core.runnables.config import (
_get_langsmith_inheritable_metadata_from_config,
_set_config_context,
ensure_config,
get_async_callback_manager_for_config,
get_callback_manager_for_config,
merge_configs,
run_in_executor,
)
@@ -232,101 +230,6 @@ def test_get_langsmith_inheritable_metadata_from_config_uses_previous_copy_rules
}
def test_get_langsmith_inheritable_metadata_pulls_allowlisted_metadata() -> None:
# Callers pre-extract allowlisted metadata and pass it via `extra_metadata`
# so that `config["metadata"]` is only iterated once.
config: RunnableConfig = {
"metadata": {
"foo": "bar",
"ls_agent_type": "react",
"ls_provider": "openai", # not on the allowlist
},
"configurable": {"baz": "qux"},
}
assert _get_langsmith_inheritable_metadata_from_config(
config, extra_metadata={"ls_agent_type": "react"}
) == {
"baz": "qux",
"ls_agent_type": "react",
}
def test_get_langsmith_inheritable_metadata_handles_missing_metadata() -> None:
# No metadata key at all
assert _get_langsmith_inheritable_metadata_from_config(
{"configurable": {"baz": "qux"}}
) == {"baz": "qux"}
# metadata present but no allowlisted keys and no configurable contributions
assert (
_get_langsmith_inheritable_metadata_from_config({"metadata": {"foo": "bar"}})
is None
)
def test_get_langsmith_inheritable_metadata_extra_metadata_overrides_configurable() -> (
None
):
# `extra_metadata` takes precedence over configurable entries with the
# same name (applied after the initial dict is built). The existing
# `key not in config_metadata` guard in the configurable pass also
# prevents configurable entries from shadowing metadata keys that the
# caller intentionally routed into `extra_metadata`.
config: RunnableConfig = {
"metadata": {"ls_agent_type": "from-metadata"},
"configurable": {"ls_agent_type": "from-configurable"},
}
assert _get_langsmith_inheritable_metadata_from_config(
config, extra_metadata={"ls_agent_type": "from-metadata"}
) == {
"ls_agent_type": "from-metadata",
}
def test_get_callback_manager_for_config_filters_allowlisted_metadata() -> None:
# Allowlisted keys (LangSmith-only) are stripped from general inheritable
# metadata so they don't reach non-tracer callback handlers or
# `stream_events` output. They reach tracers via the
# `langsmith_inheritable_metadata` path instead.
config: RunnableConfig = {
"metadata": {
"foo": "bar",
"ls_agent_type": "react",
"ls_provider": "openai", # not on the allowlist
},
}
manager = get_callback_manager_for_config(config)
# Allowlisted keys are stripped; regular keys (including non-allowlisted
# ls_* keys) pass through.
assert manager.inheritable_metadata == {"foo": "bar", "ls_provider": "openai"}
def test_get_callback_manager_for_config_preserves_empty_metadata() -> None:
# When no metadata is supplied, inheritable_metadata should remain empty
# (and the split should not raise on a missing metadata key).
manager = get_callback_manager_for_config({})
assert not manager.inheritable_metadata
async def test_get_async_callback_manager_for_config_filters_allowlisted_metadata() -> (
None
):
config: RunnableConfig = {
"metadata": {
"foo": "bar",
"ls_agent_type": "react",
},
}
manager = get_async_callback_manager_for_config(config)
assert manager.inheritable_metadata == {"foo": "bar"}
async def test_merge_config_callbacks() -> None:
manager: RunnableConfig = {
"callbacks": CallbackManager(handlers=[StdOutCallbackHandler()])

View File

@@ -1040,95 +1040,6 @@ class TestLangsmithInheritableTracingDefaultsInConfigure:
md = posts[0].get("extra", {}).get("metadata", {})
assert md["env"] == "prod"
def test_nested_ls_agent_type_is_scoped_to_each_runnable(self) -> None:
"""Allowlisted `ls_` metadata set on a nested runnable overrides the outer.
An outer runnable bound with `ls_agent_type="root"` that invokes an
inner runnable bound with `ls_agent_type="subagent"` should post two
runs where each carries its own `ls_agent_type`. In particular, the
inner run's metadata must NOT inherit `"root"` from the outer.
"""
tracer = _create_tracer_with_mocked_client()
@RunnableLambda
def inner(x: int) -> int:
return x + 1
inner_with_config = inner.with_config(
{"metadata": {"ls_agent_type": "subagent"}, "run_name": "inner"}
)
@RunnableLambda
def outer(x: int) -> int:
return inner_with_config.invoke(x)
outer_with_config = outer.with_config(
{"metadata": {"ls_agent_type": "root"}, "run_name": "outer"}
)
outer_with_config.invoke(1, {"callbacks": [tracer]})
posts = _get_posts(tracer.client)
posts_by_name = {post.get("name"): post for post in posts}
assert set(posts_by_name) >= {"outer", "inner"}, (
f"expected both outer and inner runs, got {list(posts_by_name)}"
)
outer_md = posts_by_name["outer"].get("extra", {}).get("metadata", {})
inner_md = posts_by_name["inner"].get("extra", {}).get("metadata", {})
assert outer_md.get("ls_agent_type") == "root"
assert inner_md.get("ls_agent_type") == "subagent"
def test_ls_agent_type_not_visible_to_non_tracer_handlers(self) -> None:
"""Allowlisted `ls_` metadata is tracer-only.
It must not reach non-tracer callback handlers (which would surface
it in ``stream_events``, ``astream_log``, and user-provided handlers).
"""
seen_metadata: list[dict[str, Any]] = []
class RecordingHandler(BaseCallbackHandler):
def on_chain_start(
self,
serialized: dict[str, Any] | None,
inputs: dict[str, Any] | Any,
*,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
seen_metadata.append(dict(metadata or {}))
@RunnableLambda
def inner(x: int) -> int:
return x + 1
inner_with_config = inner.with_config(
{"metadata": {"ls_agent_type": "subagent", "visible": "inner"}}
)
@RunnableLambda
def outer(x: int) -> int:
return inner_with_config.invoke(x)
outer_with_config = outer.with_config(
{"metadata": {"ls_agent_type": "root", "visible": "outer"}}
)
outer_with_config.invoke(1, {"callbacks": [RecordingHandler()]})
# Every non-tracer callback invocation must exclude `ls_agent_type`.
assert seen_metadata, "expected on_chain_start to fire"
for md in seen_metadata:
assert "ls_agent_type" not in md, (
f"ls_agent_type leaked to non-tracer handler: {md}"
)
# Regular metadata keys should still be visible so the filter is
# correctly scoped to the allowlist.
assert any(md.get("visible") == "outer" for md in seen_metadata)
assert any(md.get("visible") == "inner" for md in seen_metadata)
def test_runnable_config_copies_configurable_values_to_tracing_metadata(
self,
) -> None:

View File

@@ -3653,3 +3653,42 @@ 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

@@ -69,6 +69,7 @@ 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,8 +1,14 @@
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
@@ -18,27 +24,314 @@ 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:
def instantiate_agent() -> None:
create_agent(model=GenericFakeChatModel(messages=iter([AIMessage(content="ok")])))
benchmark(instantiate_agent)
"""Baseline: no tools."""
benchmark(lambda: create_agent(model=_make_model()))
@pytest.mark.benchmark
def test_create_agent_instantiation_with_middleware(
benchmark: BenchmarkFixture,
) -> None:
def instantiate_agent() -> None:
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:
middleware: Sequence[AgentMiddleware[Any, Any]] = (
TodoListMiddleware(),
ToolRetryMiddleware(),
ModelRetryMiddleware(),
)
create_agent(
model=GenericFakeChatModel(messages=iter([AIMessage(content="ok")])),
model=_make_model(),
tools=LARGE_TOOLS,
middleware=middleware,
)
benchmark(instantiate_agent)
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")

View File

@@ -1417,6 +1417,7 @@ 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" },
@@ -1427,6 +1428,7 @@ 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" },
@@ -1437,6 +1439,7 @@ 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" },
@@ -1447,6 +1450,7 @@ 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" },
@@ -1457,6 +1461,7 @@ 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" },
@@ -1984,6 +1989,7 @@ test = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-benchmark" },
{ name = "pytest-codspeed" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
{ name = "pytest-socket" },
@@ -2039,6 +2045,7 @@ 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" },
@@ -4165,28 +4172,24 @@ wheels = [
[[package]]
name = "pytest-codspeed"
version = "4.2.0"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
{ name = "pytest" },
{ name = "rich" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]

View File

@@ -705,6 +705,10 @@ 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

@@ -16,6 +16,7 @@ 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"
@@ -45,6 +46,92 @@ 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]
@@ -79,6 +166,7 @@ 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
@@ -91,6 +179,7 @@ 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")]
@@ -148,6 +237,7 @@ 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")]
@@ -165,6 +255,7 @@ 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")]