From 4cdd47b253d78a4987f1e0aec51b78948c2e8b09 Mon Sep 17 00:00:00 2001 From: Hunter Lovell <40191806+hntrl@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:27:29 -0600 Subject: [PATCH] fix(langchain): sanitize anthropic cache markers on fallback retries (#37867) ## Summary Fixes #33709. When `AnthropicPromptCachingMiddleware` runs before `ModelFallbackMiddleware`, Anthropic-specific `cache_control` markers can leak into fallback attempts targeting non-Anthropic models. This change makes fallback retries sanitize those markers only on non-primary attempts, preserving primary-call behavior and avoiding API changes. Related: langchain-ai/deepagentsjs#551. ## Changes ### libs/langchain_v1 - Added a private fallback sanitizer in `model_fallback.py` to remove Anthropic `cache_control` markers from fallback requests. - Sanitization covers `model_settings`, system/content message blocks, and tool payloads (`BaseTool.extras` plus dict-style tools/extras). - Applied sanitization in both sync and async fallback retry paths (`wrap_model_call` and `awrap_model_call`) while leaving the primary attempt unchanged. ### libs/langchain_v1 tests - Added sync and async regression tests in `test_model_fallback.py` that simulate primary failure and assert fallback calls only succeed when cache markers are stripped. - Verified non-cache settings (for example `temperature` and `top_p`) are preserved on fallback. - Preserved existing fallback behavior coverage (ordering, exhaustion, and error propagation). ## Compatibility with `BedrockPromptCachingMiddleware` The sanitizer also covers `BedrockPromptCachingMiddleware`, which injects `cache_control` into `model_settings` only. Bedrock-specific markers (`cachePoint` blocks, content-block `cache_control`) are applied by the chat model classes at API-call time and never appear in the `ModelRequest`, so no additional handling is needed. --------- Co-authored-by: Mason Daugherty Co-authored-by: Mason Daugherty --- .../agents/middleware/model_fallback.py | 284 +++++++- .../implementations/test_model_fallback.py | 645 +++++++++++++++++- libs/langchain_v1/uv.lock | 14 +- 3 files changed, 927 insertions(+), 16 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/model_fallback.py b/libs/langchain_v1/langchain/agents/middleware/model_fallback.py index 2bb60a2fd60..7bd1961470f 100644 --- a/libs/langchain_v1/langchain/agents/middleware/model_fallback.py +++ b/libs/langchain_v1/langchain/agents/middleware/model_fallback.py @@ -1,8 +1,24 @@ -"""Model fallback middleware for agents.""" +"""Model fallback middleware for agents. + +When a caching middleware such as `AnthropicPromptCachingMiddleware` wraps this +middleware from the outside, it applies Anthropic `cache_control` markers to the +request *before* the fallback loop runs. Those markers are provider-specific and +cause API errors on non-Anthropic fallback models, so this middleware strips them +from fallback attempts — but only when the fallback model itself cannot accept +Anthropic cache markers. When the fallback is another Anthropic model the markers +are valid and preserve prompt caching, so they are left intact. + +The knowledge of the `cache_control` marker is duplicated here (rather than owned +solely by the Anthropic partner package) because an outer caching middleware +never re-runs during fallback and therefore cannot clean up after itself. +""" from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, Any + +from langchain_core.tools import BaseTool from langchain.agents.middleware.types import ( AgentMiddleware, @@ -18,7 +34,245 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Callable from langchain_core.language_models.chat_models import BaseChatModel - from langchain_core.messages import AIMessage + from langchain_core.messages import AIMessage, AnyMessage, SystemMessage + +logger = logging.getLogger(__name__) + + +def _sanitize_content_blocks( + content: str | list[str | dict[str, Any]], +) -> str | list[str | dict[str, Any]]: + """Remove Anthropic cache markers from message content blocks.""" + if not isinstance(content, list): + return content + + sanitized_content: list[str | dict[str, Any]] = [] + changed = False + + for block in content: + if not isinstance(block, dict): + sanitized_content.append(block) + continue + + sanitized_block, block_changed = _without_cache_control_from_content_block(block) + changed = changed or block_changed + sanitized_content.append(sanitized_block) + + return sanitized_content if changed else content + + +def _sanitize_system_message( + system_message: SystemMessage | None, +) -> SystemMessage | None: + """Remove Anthropic cache markers from a system message.""" + if system_message is None: + return None + + sanitized_content = _sanitize_content_blocks(system_message.content) + if sanitized_content is system_message.content: + return system_message + + return system_message.model_copy(update={"content": sanitized_content}) + + +def _sanitize_messages(messages: list[AnyMessage]) -> list[AnyMessage]: + """Remove Anthropic cache markers from request messages.""" + sanitized_messages: list[AnyMessage] = [] + changed = False + + for message in messages: + sanitized_message, message_changed = _sanitize_message(message) + changed = changed or message_changed + sanitized_messages.append(sanitized_message) + + return sanitized_messages if changed else messages + + +def _sanitize_tools( + tools: list[BaseTool | dict[str, Any]], +) -> list[BaseTool | dict[str, Any]]: + """Remove Anthropic cache markers from tool payloads.""" + sanitized_tools: list[BaseTool | dict[str, Any]] = [] + changed = False + + for tool in tools: + sanitized_tool: BaseTool | dict[str, Any] + if isinstance(tool, BaseTool): + sanitized_tool, tool_changed = _sanitize_base_tool(tool) + else: + sanitized_tool, tool_changed = _sanitize_dict_tool(tool) + + changed = changed or tool_changed + sanitized_tools.append(sanitized_tool) + + return sanitized_tools if changed else tools + + +def _sanitize_request_for_fallback(request: ModelRequest[ContextT]) -> ModelRequest[ContextT]: + """Sanitize provider-specific Anthropic cache markers before fallback attempts.""" + overrides: dict[str, Any] = {} + + model_settings, model_settings_changed = _without_cache_control(request.model_settings) + if model_settings_changed: + overrides["model_settings"] = model_settings + + system_message = _sanitize_system_message(request.system_message) + if system_message is not request.system_message: + overrides["system_message"] = system_message + + messages = _sanitize_messages(request.messages) + if messages is not request.messages: + overrides["messages"] = messages + + tools = _sanitize_tools(request.tools) + if tools is not request.tools: + overrides["tools"] = tools + + if not overrides: + return request + + # Log only the field names that changed, never request content (may contain + # prompt data or PII). + logger.debug( + "Stripped Anthropic cache_control markers from %s before fallback attempt", + sorted(overrides), + ) + + return request.override(**overrides) + + +def _sanitize_message(message: AnyMessage) -> tuple[AnyMessage, bool]: + """Remove Anthropic cache markers from a single message. + + Returns: + The sanitized message (the original instance when unchanged) and whether + any marker was removed. + """ + sanitized_content = _sanitize_content_blocks(message.content) + if sanitized_content is message.content: + return message, False + + return message.model_copy(update={"content": sanitized_content}), True + + +def _sanitize_base_tool(tool: BaseTool) -> tuple[BaseTool, bool]: + """Remove Anthropic cache markers from a `BaseTool` payload. + + Returns: + The sanitized tool (the original instance when unchanged) and whether any + marker was removed. + + Emptied `extras` collapse back to `None`. + """ + if not tool.extras: + return tool, False + + sanitized_extras, changed = _without_cache_control(tool.extras) + if not changed: + return tool, False + + return tool.model_copy(update={"extras": sanitized_extras or None}), True + + +def _sanitize_dict_tool(tool: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """Remove Anthropic cache markers from a dict-style tool payload. + + Returns: + The sanitized tool (the original instance when unchanged) and whether any + marker was removed. + + Emptied `extras` collapse back to `None`. + """ + sanitized_tool, changed = _without_cache_control(tool) + + extras = sanitized_tool.get("extras") + if not isinstance(extras, dict): + return sanitized_tool, changed + + sanitized_extras, extras_changed = _without_cache_control(extras) + if not extras_changed: + return sanitized_tool, changed + + return {**sanitized_tool, "extras": sanitized_extras or None}, True + + +def _without_cache_control(payload: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """Return payload without `cache_control`, plus whether anything changed.""" + if "cache_control" not in payload: + return payload, False + + return ( + {key: value for key, value in payload.items() if key != "cache_control"}, + True, + ) + + +def _without_cache_control_from_content_block( + block: dict[str, Any], +) -> tuple[dict[str, Any], bool]: + """Return content block without Anthropic cache markers. + + Strips `cache_control` from the block itself and from its nested `extras` and + `metadata` payloads. + + Returns: + The sanitized block (the original instance when unchanged) and whether any + marker was removed. + """ + sanitized_block, changed = _without_cache_control(block) + + for nested_key in ("extras", "metadata"): + nested_payload = sanitized_block.get(nested_key) + if not isinstance(nested_payload, dict): + continue + + sanitized_payload, nested_changed = _without_cache_control(nested_payload) + if not nested_changed: + continue + + if sanitized_block is block: + sanitized_block = dict(block) + sanitized_block[nested_key] = sanitized_payload + changed = True + + return sanitized_block, changed + + +# `_llm_type` values that indicate a model speaks an Anthropic-compatible API +# and therefore accepts `cache_control` markers. Direct Anthropic models +# (`ChatAnthropic`) report `"anthropic-chat"`; Bedrock-hosted Claude +# (`ChatAnthropicBedrock`, a `ChatAnthropic` subclass in `langchain-aws`) reports +# `"anthropic-bedrock-chat"` and translates the top-level kwarg into block-level +# breakpoints inside the inherited `ChatAnthropic._get_request_payload`, while +# content-block and tool `cache_control` markers pass through unchanged. +# Vertex-hosted Claude (`ChatAnthropicVertex` in `langchain-google`) reports +# `"anthropic-chat-vertexai"` and nests the same marker shape through its own +# request builder — not the shared `ChatAnthropic` method. All three keep prompt +# caching intact on fallback. +# +# Keep this set in sync with those classes' `_llm_type` values, which live in +# separate repositories. If a value drifts or a new Anthropic transport ships, +# the failure mode is silent loss of prompt caching (markers stripped from a +# model that supports them), not a hard error — so CI here will not catch it. +_ANTHROPIC_LLM_TYPES: frozenset[str] = frozenset( + { + "anthropic-chat", + "anthropic-bedrock-chat", + "anthropic-chat-vertexai", + } +) + + +def _supports_anthropic_cache_control(model: BaseChatModel) -> bool: + """Return whether `model` accepts Anthropic `cache_control` markers. + + Checked via `_llm_type` so the decision is provider-based rather than + model-name-based: any Anthropic-compatible model (including future model IDs + we have not seen) keeps its cache markers on fallback, while OpenAI, Gemini, + and other non-Anthropic providers get a sanitized request. + """ + llm_type = getattr(model, "_llm_type", None) + return isinstance(llm_type, str) and llm_type in _ANTHROPIC_LLM_TYPES class ModelFallbackMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, ResponseT]): @@ -93,10 +347,18 @@ class ModelFallbackMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R except Exception as e: last_exception = e - # Try fallback models + # Try fallback models — sanitize cache markers only when the fallback + # model cannot accept them (i.e. is not an Anthropic-compatible model). + # The request is derived outside the try so a sanitizer or `_llm_type` + # bug surfaces directly instead of being masked as a model failure. for fallback_model in self.models: + fallback_request = ( + request + if _supports_anthropic_cache_control(fallback_model) + else _sanitize_request_for_fallback(request) + ) try: - return handler(request.override(model=fallback_model)) + return handler(fallback_request.override(model=fallback_model)) except Exception as e: last_exception = e continue @@ -127,10 +389,18 @@ class ModelFallbackMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R except Exception as e: last_exception = e - # Try fallback models + # Try fallback models — sanitize cache markers only when the fallback + # model cannot accept them (i.e. is not an Anthropic-compatible model). + # The request is derived outside the try so a sanitizer or `_llm_type` + # bug surfaces directly instead of being masked as a model failure. for fallback_model in self.models: + fallback_request = ( + request + if _supports_anthropic_cache_control(fallback_model) + else _sanitize_request_for_fallback(request) + ) try: - return await handler(request.override(model=fallback_model)) + return await handler(fallback_request.override(model=fallback_model)) except Exception as e: last_exception = e continue diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_model_fallback.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_model_fallback.py index c268f6c5656..39eca31ae9c 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_model_fallback.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_model_fallback.py @@ -10,10 +10,16 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.language_models.fake_chat_models import GenericFakeChatModel from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import BaseTool, tool from typing_extensions import override from langchain.agents.factory import create_agent -from langchain.agents.middleware.model_fallback import ModelFallbackMiddleware +from langchain.agents.middleware import model_fallback as model_fallback_module +from langchain.agents.middleware.model_fallback import ( + ModelFallbackMiddleware, + _sanitize_request_for_fallback, + _supports_anthropic_cache_control, +) from langchain.agents.middleware.types import AgentState, ModelRequest, ModelResponse from tests.unit_tests.agents.model import FakeToolCallingModel @@ -42,6 +48,294 @@ def _make_request() -> ModelRequest: ) +def _make_request_with_cache_markers(primary_model: BaseChatModel) -> ModelRequest: + """Create a request with Anthropic-style cache markers in all relevant fields.""" + + @tool(extras={"cache_control": {"type": "ephemeral"}, "defer_loading": True}) + def cached_tool(query: str) -> str: + """Tool used for cache marker sanitization tests.""" + return query + + return _make_request().override( + model=primary_model, + model_settings={ + "temperature": 0.3, + "top_p": 0.8, + "cache_control": {"type": "ephemeral"}, + }, + system_message=SystemMessage( + content=[ + {"type": "text", "text": "policy", "cache_control": {"type": "ephemeral"}}, + { + "type": "text", + "text": "extra", + "extras": { + "priority": "high", + "cache_control": {"type": "ephemeral"}, + }, + }, + ] + ), + messages=[ + HumanMessage( + content=[ + { + "type": "text", + "text": "question", + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "text", + "text": "details", + "metadata": { + "source": "user", + "cache_control": {"type": "ephemeral"}, + }, + }, + ] + ), + AIMessage( + content=[ + { + "type": "text", + "text": "previous", + "extras": { + "turn": 1, + "cache_control": {"type": "ephemeral"}, + }, + "metadata": { + "source": "assistant", + "cache_control": {"type": "ephemeral"}, + }, + }, + ] + ), + ], + tools=[ + cached_tool, + { + "name": "dict_tool", + "description": "dict-style tool payload", + "input_schema": {"type": "object", "properties": {}}, + "cache_control": {"type": "ephemeral"}, + "extras": { + "defer_loading": True, + "cache_control": {"type": "ephemeral"}, + }, + }, + ], + ) + + +def _assert_request_has_cache_markers(request: ModelRequest) -> None: + """Assert request still contains Anthropic-style cache markers.""" + assert "cache_control" in request.model_settings + assert request.system_message is not None + system_content = request.system_message.content + assert isinstance(system_content, list) + assert isinstance(system_content[0], dict) + assert "cache_control" in system_content[0] + assert isinstance(system_content[1], dict) + system_extras = system_content[1].get("extras") + assert isinstance(system_extras, dict) + assert "cache_control" in system_extras + + first_message_content = request.messages[0].content + assert isinstance(first_message_content, list) + assert isinstance(first_message_content[0], dict) + assert "cache_control" in first_message_content[0] + assert isinstance(first_message_content[1], dict) + first_message_metadata = first_message_content[1].get("metadata") + assert isinstance(first_message_metadata, dict) + assert "cache_control" in first_message_metadata + + second_message_content = request.messages[1].content + assert isinstance(second_message_content, list) + assert isinstance(second_message_content[0], dict) + second_message_extras = second_message_content[0].get("extras") + assert isinstance(second_message_extras, dict) + assert "cache_control" in second_message_extras + second_message_metadata = second_message_content[0].get("metadata") + assert isinstance(second_message_metadata, dict) + assert "cache_control" in second_message_metadata + + base_tool = request.tools[0] + assert isinstance(base_tool, BaseTool) + assert base_tool.extras is not None + assert "cache_control" in base_tool.extras + + dict_tool = request.tools[1] + assert isinstance(dict_tool, dict) + assert "cache_control" in dict_tool + dict_tool_extras = dict_tool.get("extras") + assert isinstance(dict_tool_extras, dict) + assert "cache_control" in dict_tool_extras + + +def _assert_request_is_sanitized(request: ModelRequest) -> None: + """Assert request has cache markers removed while preserving unrelated data.""" + assert request.model_settings == {"temperature": 0.3, "top_p": 0.8} + + assert request.system_message is not None + system_content = request.system_message.content + assert isinstance(system_content, list) + assert all( + not (isinstance(block, dict) and "cache_control" in block) for block in system_content + ) + assert isinstance(system_content[1], dict) + assert system_content[1].get("extras") == {"priority": "high"} + + for message in request.messages: + message_content = message.content + if isinstance(message_content, list): + assert all( + not (isinstance(block, dict) and "cache_control" in block) + for block in message_content + ) + + first_message_content = request.messages[0].content + assert isinstance(first_message_content, list) + assert isinstance(first_message_content[1], dict) + assert first_message_content[1].get("metadata") == {"source": "user"} + + second_message_content = request.messages[1].content + assert isinstance(second_message_content, list) + assert isinstance(second_message_content[0], dict) + assert second_message_content[0].get("extras") == {"turn": 1} + assert second_message_content[0].get("metadata") == {"source": "assistant"} + + base_tool = request.tools[0] + assert isinstance(base_tool, BaseTool) + assert base_tool.extras is not None + assert base_tool.extras == {"defer_loading": True} + + dict_tool = request.tools[1] + assert isinstance(dict_tool, dict) + assert "cache_control" not in dict_tool + dict_tool_extras = dict_tool.get("extras") + assert dict_tool_extras == {"defer_loading": True} + + +def test_fallback_sanitizes_cache_markers_sync() -> None: + """Fallback attempts should strip Anthropic cache markers in sync path.""" + primary_model = GenericFakeChatModel(messages=iter([AIMessage(content="primary response")])) + fallback_model = GenericFakeChatModel(messages=iter([AIMessage(content="fallback response")])) + middleware = ModelFallbackMiddleware(fallback_model) + request = _make_request_with_cache_markers(primary_model) + attempts: list[ModelRequest] = [] + + def mock_handler(req: ModelRequest) -> ModelResponse: + attempts.append(req) + if len(attempts) == 1: + _assert_request_has_cache_markers(req) + msg = "Primary model failed" + raise ValueError(msg) + + assert req.model is fallback_model + _assert_request_is_sanitized(req) + return ModelResponse(result=[AIMessage(content="fallback response")]) + + response = middleware.wrap_model_call(request, mock_handler) + + assert isinstance(response, ModelResponse) + assert response.result[0].content == "fallback response" + assert len(attempts) == 2 + _assert_request_has_cache_markers(request) + + +async def test_fallback_sanitizes_cache_markers_async() -> None: + """Fallback attempts should strip Anthropic cache markers in async path.""" + primary_model = GenericFakeChatModel(messages=iter([AIMessage(content="primary response")])) + fallback_model = GenericFakeChatModel(messages=iter([AIMessage(content="fallback response")])) + middleware = ModelFallbackMiddleware(fallback_model) + request = _make_request_with_cache_markers(primary_model) + attempts: list[ModelRequest] = [] + + async def mock_handler(req: ModelRequest) -> ModelResponse: + attempts.append(req) + if len(attempts) == 1: + _assert_request_has_cache_markers(req) + msg = "Primary model failed" + raise ValueError(msg) + + assert req.model is fallback_model + _assert_request_is_sanitized(req) + return ModelResponse(result=[AIMessage(content="fallback response")]) + + response = await middleware.awrap_model_call(request, mock_handler) + + assert isinstance(response, ModelResponse) + assert response.result[0].content == "fallback response" + assert len(attempts) == 2 + _assert_request_has_cache_markers(request) + + +def test_sanitize_collapses_emptied_extras_to_none() -> None: + """Stripping the only `extras` key (`cache_control`) resets `extras` to None.""" + + @tool(extras={"cache_control": {"type": "ephemeral"}}) + def base_tool_only_cache(query: str) -> str: + """Tool whose extras hold only a cache marker.""" + return query + + request = _make_request().override( + tools=[ + base_tool_only_cache, + { + "name": "dict_tool", + "description": "dict-style tool payload", + "extras": {"cache_control": {"type": "ephemeral"}}, + }, + ], + ) + + sanitized = _sanitize_request_for_fallback(request) + + base_tool = sanitized.tools[0] + assert isinstance(base_tool, BaseTool) + assert base_tool.extras is None + + dict_tool = sanitized.tools[1] + assert isinstance(dict_tool, dict) + assert dict_tool.get("extras") is None + + +def test_sanitize_returns_same_request_when_no_markers() -> None: + """A marker-free request passes through as the same instance (no copies).""" + + @tool + def plain_tool(query: str) -> str: + """Marker-free tool.""" + return query + + request = _make_request().override( + model_settings={"temperature": 0.5}, + system_message=SystemMessage(content=[{"type": "text", "text": "policy"}]), + messages=[HumanMessage(content=[{"type": "text", "text": "question"}])], + tools=[plain_tool], + ) + + assert _sanitize_request_for_fallback(request) is request + + +def test_sanitize_preserves_identity_of_unchanged_fields() -> None: + """Only fields containing markers are copied; the rest keep object identity.""" + request = _make_request().override( + model_settings={"temperature": 0.5, "cache_control": {"type": "ephemeral"}}, + system_message=SystemMessage(content=[{"type": "text", "text": "policy"}]), + messages=[HumanMessage(content=[{"type": "text", "text": "question"}])], + ) + + sanitized = _sanitize_request_for_fallback(request) + + # Only `model_settings` contained a marker, so everything else is untouched. + assert sanitized is not request + assert sanitized.model_settings == {"temperature": 0.5} + assert sanitized.system_message is request.system_message + assert sanitized.messages is request.messages + assert sanitized.tools is request.tools + + def test_primary_model_succeeds() -> None: """Test that primary model is used when it succeeds.""" primary_model = GenericFakeChatModel(messages=iter([AIMessage(content="primary response")])) @@ -424,3 +718,352 @@ def test_model_request_is_frozen() -> None: # Original request should be unchanged assert request2.model != new_model assert request2.system_prompt != "override prompt" + + +class _FakeAnthropicModel(GenericFakeChatModel): + """Fake model that reports `anthropic-chat` as its `_llm_type`. + + This simulates a direct-Anthropic model for provider-based cache-control + detection without importing the real `ChatAnthropic` (which requires + `langchain-anthropic`). + """ + + @property + def _llm_type(self) -> str: + return "anthropic-chat" + + +class _FakeBedrockAnthropicModel(GenericFakeChatModel): + """Fake model that reports `anthropic-bedrock-chat` as its `_llm_type`. + + Simulates a Bedrock-hosted Claude model. `ChatAnthropic._get_request_payload` + translates the top-level `cache_control` kwarg into a block-level breakpoint + for this `_llm_type`, while content-block and tool markers pass through, so + cache markers are valid for this provider. + """ + + @property + def _llm_type(self) -> str: + return "anthropic-bedrock-chat" + + +class _FakeVertexAnthropicModel(GenericFakeChatModel): + """Fake model that reports `anthropic-chat-vertexai` as its `_llm_type`.""" + + @property + def _llm_type(self) -> str: + return "anthropic-chat-vertexai" + + +class _FakeNonStringLlmTypeModel(GenericFakeChatModel): + """Fake whose `_llm_type` is not a string, exercising the `isinstance` guard. + + A list is deliberately unhashable, so without the guard the frozenset + membership test would raise `TypeError` rather than return `False`. + """ + + @property + def _llm_type(self) -> str: + return ["anthropic-chat"] # type: ignore[return-value] + + +_ANTHROPIC_COMPATIBLE_FAKES = [ + _FakeAnthropicModel, + _FakeBedrockAnthropicModel, + _FakeVertexAnthropicModel, +] + + +def test_supports_anthropic_cache_control() -> None: + """`_supports_anthropic_cache_control` detects Anthropic-compatible models.""" + assert _supports_anthropic_cache_control(_FakeAnthropicModel(messages=iter([]))) + assert _supports_anthropic_cache_control(_FakeBedrockAnthropicModel(messages=iter([]))) + assert _supports_anthropic_cache_control(_FakeVertexAnthropicModel(messages=iter([]))) + assert not _supports_anthropic_cache_control(GenericFakeChatModel(messages=iter([]))) + assert not _supports_anthropic_cache_control(FakeToolCallingModel()) + # A non-string `_llm_type` must be rejected by the guard rather than raising. + assert not _supports_anthropic_cache_control(_FakeNonStringLlmTypeModel(messages=iter([]))) + + +def test_fallback_preserves_cache_markers_for_anthropic_sync() -> None: + """Anthropic fallback keeps cache markers; non-Anthropic fallback strips them.""" + primary_model = _FakeAnthropicModel(messages=iter([AIMessage(content="primary response")])) + anthropic_fallback = _FakeAnthropicModel( + messages=iter([AIMessage(content="anthropic fallback")]) + ) + non_anthropic_fallback = GenericFakeChatModel( + messages=iter([AIMessage(content="non-anthropic fallback")]) + ) + middleware = ModelFallbackMiddleware(anthropic_fallback, non_anthropic_fallback) + request = _make_request_with_cache_markers(primary_model) + attempts: list[ModelRequest] = [] + + def mock_handler(req: ModelRequest) -> ModelResponse: + attempts.append(req) + if len(attempts) == 1: + # Primary attempt — markers present + _assert_request_has_cache_markers(req) + msg = "Primary model failed" + raise ValueError(msg) + if len(attempts) == 2: + # Anthropic fallback — markers preserved + assert req.model is anthropic_fallback + _assert_request_has_cache_markers(req) + msg = "Anthropic fallback failed" + raise ValueError(msg) + # Non-Anthropic fallback — markers stripped + assert req.model is non_anthropic_fallback + _assert_request_is_sanitized(req) + return ModelResponse(result=[AIMessage(content="non-anthropic fallback")]) + + response = middleware.wrap_model_call(request, mock_handler) + + assert isinstance(response, ModelResponse) + assert response.result[0].content == "non-anthropic fallback" + assert len(attempts) == 3 + _assert_request_has_cache_markers(request) + + +async def test_fallback_preserves_cache_markers_for_anthropic_async() -> None: + """Async: Anthropic fallback keeps cache markers; non-Anthropic strips them.""" + primary_model = _FakeAnthropicModel(messages=iter([AIMessage(content="primary response")])) + anthropic_fallback = _FakeAnthropicModel( + messages=iter([AIMessage(content="anthropic fallback")]) + ) + non_anthropic_fallback = GenericFakeChatModel( + messages=iter([AIMessage(content="non-anthropic fallback")]) + ) + middleware = ModelFallbackMiddleware(anthropic_fallback, non_anthropic_fallback) + request = _make_request_with_cache_markers(primary_model) + attempts: list[ModelRequest] = [] + + async def mock_handler(req: ModelRequest) -> ModelResponse: + attempts.append(req) + if len(attempts) == 1: + _assert_request_has_cache_markers(req) + msg = "Primary model failed" + raise ValueError(msg) + if len(attempts) == 2: + assert req.model is anthropic_fallback + _assert_request_has_cache_markers(req) + msg = "Anthropic fallback failed" + raise ValueError(msg) + assert req.model is non_anthropic_fallback + _assert_request_is_sanitized(req) + return ModelResponse(result=[AIMessage(content="non-anthropic fallback")]) + + response = await middleware.awrap_model_call(request, mock_handler) + + assert isinstance(response, ModelResponse) + assert response.result[0].content == "non-anthropic fallback" + assert len(attempts) == 3 + _assert_request_has_cache_markers(request) + + +@pytest.mark.parametrize("fallback_cls", _ANTHROPIC_COMPATIBLE_FAKES) +def test_fallback_preserves_cache_markers_for_anthropic_compatible_sync( + fallback_cls: type[GenericFakeChatModel], +) -> None: + """Any Anthropic-compatible fallback (direct, Bedrock, Vertex) keeps markers.""" + primary_model = _FakeAnthropicModel(messages=iter([AIMessage(content="primary response")])) + fallback = fallback_cls(messages=iter([AIMessage(content="fallback")])) + middleware = ModelFallbackMiddleware(fallback) + request = _make_request_with_cache_markers(primary_model) + attempts: list[ModelRequest] = [] + + def mock_handler(req: ModelRequest) -> ModelResponse: + attempts.append(req) + if len(attempts) == 1: + _assert_request_has_cache_markers(req) + msg = "Primary model failed" + raise ValueError(msg) + # Anthropic-compatible fallback — markers preserved + assert req.model is fallback + _assert_request_has_cache_markers(req) + return ModelResponse(result=[AIMessage(content="fallback")]) + + response = middleware.wrap_model_call(request, mock_handler) + + assert isinstance(response, ModelResponse) + assert response.result[0].content == "fallback" + assert len(attempts) == 2 + _assert_request_has_cache_markers(request) + + +@pytest.mark.parametrize("fallback_cls", _ANTHROPIC_COMPATIBLE_FAKES) +async def test_fallback_preserves_cache_markers_for_anthropic_compatible_async( + fallback_cls: type[GenericFakeChatModel], +) -> None: + """Async: any Anthropic-compatible fallback (direct, Bedrock, Vertex) keeps markers.""" + primary_model = _FakeAnthropicModel(messages=iter([AIMessage(content="primary response")])) + fallback = fallback_cls(messages=iter([AIMessage(content="fallback")])) + middleware = ModelFallbackMiddleware(fallback) + request = _make_request_with_cache_markers(primary_model) + attempts: list[ModelRequest] = [] + + async def mock_handler(req: ModelRequest) -> ModelResponse: + attempts.append(req) + if len(attempts) == 1: + _assert_request_has_cache_markers(req) + msg = "Primary model failed" + raise ValueError(msg) + assert req.model is fallback + _assert_request_has_cache_markers(req) + return ModelResponse(result=[AIMessage(content="fallback")]) + + response = await middleware.awrap_model_call(request, mock_handler) + + assert isinstance(response, ModelResponse) + assert response.result[0].content == "fallback" + assert len(attempts) == 2 + _assert_request_has_cache_markers(request) + + +def test_fallback_reverse_order_preserves_anthropic_markers_sync() -> None: + """A non-Anthropic fallback first must not corrupt a later Anthropic fallback. + + Each iteration derives its request from the original, so the Anthropic + fallback still sees cache markers even though the earlier non-Anthropic + fallback received a sanitized request. Guards against a regression to + loop-carried reassignment of the request. + """ + primary_model = _FakeAnthropicModel(messages=iter([AIMessage(content="primary response")])) + non_anthropic_fallback = GenericFakeChatModel( + messages=iter([AIMessage(content="non-anthropic fallback")]) + ) + anthropic_fallback = _FakeAnthropicModel( + messages=iter([AIMessage(content="anthropic fallback")]) + ) + middleware = ModelFallbackMiddleware(non_anthropic_fallback, anthropic_fallback) + request = _make_request_with_cache_markers(primary_model) + attempts: list[ModelRequest] = [] + + def mock_handler(req: ModelRequest) -> ModelResponse: + attempts.append(req) + if len(attempts) == 1: + _assert_request_has_cache_markers(req) + msg = "Primary model failed" + raise ValueError(msg) + if len(attempts) == 2: + # Non-Anthropic fallback first — markers stripped + assert req.model is non_anthropic_fallback + _assert_request_is_sanitized(req) + msg = "Non-Anthropic fallback failed" + raise ValueError(msg) + # Anthropic fallback second — markers still present (derived from original) + assert req.model is anthropic_fallback + _assert_request_has_cache_markers(req) + return ModelResponse(result=[AIMessage(content="anthropic fallback")]) + + response = middleware.wrap_model_call(request, mock_handler) + + assert isinstance(response, ModelResponse) + assert response.result[0].content == "anthropic fallback" + assert len(attempts) == 3 + _assert_request_has_cache_markers(request) + + +async def test_fallback_reverse_order_preserves_anthropic_markers_async() -> None: + """Async: non-Anthropic fallback first must not corrupt a later Anthropic fallback.""" + primary_model = _FakeAnthropicModel(messages=iter([AIMessage(content="primary response")])) + non_anthropic_fallback = GenericFakeChatModel( + messages=iter([AIMessage(content="non-anthropic fallback")]) + ) + anthropic_fallback = _FakeAnthropicModel( + messages=iter([AIMessage(content="anthropic fallback")]) + ) + middleware = ModelFallbackMiddleware(non_anthropic_fallback, anthropic_fallback) + request = _make_request_with_cache_markers(primary_model) + attempts: list[ModelRequest] = [] + + async def mock_handler(req: ModelRequest) -> ModelResponse: + attempts.append(req) + if len(attempts) == 1: + _assert_request_has_cache_markers(req) + msg = "Primary model failed" + raise ValueError(msg) + if len(attempts) == 2: + assert req.model is non_anthropic_fallback + _assert_request_is_sanitized(req) + msg = "Non-Anthropic fallback failed" + raise ValueError(msg) + assert req.model is anthropic_fallback + _assert_request_has_cache_markers(req) + return ModelResponse(result=[AIMessage(content="anthropic fallback")]) + + response = await middleware.awrap_model_call(request, mock_handler) + + assert isinstance(response, ModelResponse) + assert response.result[0].content == "anthropic fallback" + assert len(attempts) == 3 + _assert_request_has_cache_markers(request) + + +def test_fallback_sanitizer_error_is_not_masked_sync( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A sanitizer bug must surface, not be swallowed and hidden by a later success. + + The sanitized request is built outside the ``try`` that guards the model + call, so an exception from sanitization propagates immediately instead of + being caught, recorded as a model failure, and masked when a subsequent + Anthropic fallback succeeds. + """ + + def _boom(_request: ModelRequest) -> ModelRequest: + msg = "sanitizer boom" + raise RuntimeError(msg) + + monkeypatch.setattr(model_fallback_module, "_sanitize_request_for_fallback", _boom) + + primary_model = _FakeAnthropicModel(messages=iter([AIMessage(content="primary response")])) + non_anthropic_fallback = GenericFakeChatModel( + messages=iter([AIMessage(content="non-anthropic fallback")]) + ) + anthropic_fallback = _FakeAnthropicModel( + messages=iter([AIMessage(content="anthropic fallback")]) + ) + middleware = ModelFallbackMiddleware(non_anthropic_fallback, anthropic_fallback) + request = _make_request_with_cache_markers(primary_model) + + def mock_handler(req: ModelRequest) -> ModelResponse: + if req.model is primary_model: + msg = "Primary model failed" + raise ValueError(msg) + # The Anthropic fallback must never be reached: the sanitizer error on + # the preceding non-Anthropic fallback should have propagated first. + return ModelResponse(result=[AIMessage(content="should not be reached")]) + + with pytest.raises(RuntimeError, match="sanitizer boom"): + middleware.wrap_model_call(request, mock_handler) + + +async def test_fallback_sanitizer_error_is_not_masked_async( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Async: a sanitizer bug must surface, not be masked by a later success.""" + + def _boom(_request: ModelRequest) -> ModelRequest: + msg = "sanitizer boom" + raise RuntimeError(msg) + + monkeypatch.setattr(model_fallback_module, "_sanitize_request_for_fallback", _boom) + + primary_model = _FakeAnthropicModel(messages=iter([AIMessage(content="primary response")])) + non_anthropic_fallback = GenericFakeChatModel( + messages=iter([AIMessage(content="non-anthropic fallback")]) + ) + anthropic_fallback = _FakeAnthropicModel( + messages=iter([AIMessage(content="anthropic fallback")]) + ) + middleware = ModelFallbackMiddleware(non_anthropic_fallback, anthropic_fallback) + request = _make_request_with_cache_markers(primary_model) + + async def mock_handler(req: ModelRequest) -> ModelResponse: + if req.model is primary_model: + msg = "Primary model failed" + raise ValueError(msg) + return ModelResponse(result=[AIMessage(content="should not be reached")]) + + with pytest.raises(RuntimeError, match="sanitizer boom"): + await middleware.awrap_model_call(request, mock_handler) diff --git a/libs/langchain_v1/uv.lock b/libs/langchain_v1/uv.lock index 6347ccd281b..aff19eab190 100644 --- a/libs/langchain_v1/uv.lock +++ b/libs/langchain_v1/uv.lock @@ -1047,7 +1047,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1476,7 +1476,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" }, @@ -1487,7 +1486,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" }, @@ -1498,7 +1496,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" }, @@ -1509,7 +1506,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" }, @@ -1520,7 +1516,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" }, @@ -2596,10 +2591,13 @@ test-integration = [ { name = "transformers", specifier = ">=4.51.3,<6.0.0" }, ] typing = [ - { name = "beautifulsoup4", specifier = ">=4.13.5,<5.0.0" }, { name = "lxml-stubs", specifier = ">=0.5.1,<1.0.0" }, - { name = "mypy", specifier = ">=2.1.0,<2.2.0" }, + { name = "nltk", specifier = ">=3.9.1,<4.0.0" }, + { name = "sentence-transformers", specifier = ">=5.3.0,<6.0.0" }, + { name = "spacy", specifier = ">=3.8.13,<4.0.0" }, { name = "tiktoken", specifier = ">=0.8.0,<1.0.0" }, + { name = "transformers", specifier = ">=4.51.3,<6.0.0" }, + { name = "ty", specifier = ">=0.0.56,<0.1.0" }, { name = "types-requests", specifier = ">=2.31.0.20240218,<3.0.0.0" }, ]