mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-11 09:26:29 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
14
libs/langchain_v1/uv.lock
generated
14
libs/langchain_v1/uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user