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 <mason@langchain.dev>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
Hunter Lovell
2026-07-04 22:27:29 -06:00
committed by GitHub
parent 08c95c9f5b
commit 4cdd47b253
3 changed files with 927 additions and 16 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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" },
]