fix: detect Anthropic preview tool-use blocks (#6629)

* fix: detect anthropic preview tool-use blocks

* fix: preserve typed Anthropic tool-use blocks

* fix: bump gitpython for vulnerability scan

* docs: clarify gitpython advisory ranges

---------

Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
This commit is contained in:
alex-clawd
2026-07-24 11:32:38 -07:00
committed by GitHub
parent c06043f7e8
commit c528e8bdee
5 changed files with 402 additions and 78 deletions

View File

@@ -107,7 +107,7 @@ stagehand = [
"stagehand>=0.4.1",
]
github = [
"gitpython>=3.1.51,<4",
"gitpython>=3.1.55,<4",
"PyGithub==1.59.1",
]
rag = [

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import logging
import os
from typing import Any, Final, Literal, TypeGuard, cast
from typing import Any, Final, Literal, Protocol, TypeGuard, TypedDict, cast
from pydantic import BaseModel, PrivateAttr, model_validator
@@ -124,6 +124,68 @@ def _contains_file_id_reference(messages: list[dict[str, Any]]) -> bool:
return False
class _ToolUseDictBlock(TypedDict):
type: Literal["tool_use"]
id: str
name: str
input: dict[str, Any]
class _ToolUseObjectBlock(Protocol):
type: Literal["tool_use"]
id: str
name: str
input: dict[str, object]
_AnthropicToolUseBlock = (
ToolUseBlock | BetaToolUseBlock | _ToolUseDictBlock | _ToolUseObjectBlock
)
def _is_tool_use_block(block: Any) -> TypeGuard[_AnthropicToolUseBlock]:
"""Return true for complete Anthropic tool-use blocks across SDK shapes.
New/preview Anthropic models can return content blocks whose concrete SDK
class is not one of the imported ``ToolUseBlock`` aliases. The stable API
contract used by execution, streaming, and follow-up paths is ``type``,
``id``, ``name``, and ``input``, so validate that full shape before treating
a block as tool-use.
"""
if isinstance(block, dict):
return (
block.get("type") == "tool_use"
and isinstance(block.get("id"), str)
and isinstance(block.get("name"), str)
and isinstance(block.get("input"), dict)
)
return (
getattr(block, "type", None) == "tool_use"
and isinstance(getattr(block, "id", None), str)
and isinstance(getattr(block, "name", None), str)
and isinstance(getattr(block, "input", None), dict)
)
def _tool_use_blocks(blocks: list[Any]) -> list[_AnthropicToolUseBlock]:
return [block for block in blocks if _is_tool_use_block(block)]
def _tool_use_id(block: _AnthropicToolUseBlock) -> str:
return block["id"] if isinstance(block, dict) else block.id
def _tool_use_name(block: _AnthropicToolUseBlock) -> str:
return block["name"] if isinstance(block, dict) else block.name
def _tool_use_input(block: _AnthropicToolUseBlock) -> dict[str, Any]:
return (
block["input"] if isinstance(block, dict) else cast(dict[str, Any], block.input)
)
class AnthropicThinkingConfig(BaseModel):
type: Literal["enabled", "disabled"]
budget_tokens: int | None = None
@@ -589,20 +651,41 @@ class AnthropicCompletion(BaseLLM):
Returns:
Dictionary with thinking block data including signature, or None if not a thinking block
"""
if content_block.type == "thinking":
block_type = (
content_block.get("type")
if isinstance(content_block, dict)
else getattr(content_block, "type", None)
)
if block_type == "thinking":
thinking = (
content_block.get("thinking")
if isinstance(content_block, dict)
else content_block.thinking
)
thinking_block = {
"type": "thinking",
"thinking": content_block.thinking,
"thinking": thinking,
}
if hasattr(content_block, "signature"):
thinking_block["signature"] = content_block.signature
signature = (
content_block.get("signature")
if isinstance(content_block, dict)
else getattr(content_block, "signature", None)
)
if signature:
thinking_block["signature"] = signature
return thinking_block
if content_block.type == "redacted_thinking":
if block_type == "redacted_thinking":
redacted_block = {"type": "redacted_thinking"}
if hasattr(content_block, "thinking"):
redacted_block["thinking"] = content_block.thinking
if hasattr(content_block, "signature"):
redacted_block["signature"] = content_block.signature
if isinstance(content_block, dict):
if "thinking" in content_block:
redacted_block["thinking"] = content_block["thinking"]
if "signature" in content_block:
redacted_block["signature"] = content_block["signature"]
else:
if hasattr(content_block, "thinking"):
redacted_block["thinking"] = content_block.thinking
if hasattr(content_block, "signature"):
redacted_block["signature"] = content_block.signature
return redacted_block
return None
@@ -944,10 +1027,12 @@ class AnthropicCompletion(BaseLLM):
else:
for block in response.content:
if (
isinstance(block, (ToolUseBlock, BetaToolUseBlock))
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_name(block) == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_input(block)
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -962,11 +1047,7 @@ class AnthropicCompletion(BaseLLM):
# Check if Claude wants to use tools
if response.content:
tool_uses = [
block
for block in response.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(response.content))
if tool_uses:
# Without available_functions, return tool calls so the executor can
@@ -1093,11 +1174,13 @@ class AnthropicCompletion(BaseLLM):
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
if _is_tool_use_block(block):
block_index = event.index
tool_use_id = _tool_use_id(block)
tool_name = _tool_use_name(block)
current_tool_calls[block_index] = {
"id": block.id,
"name": block.name,
"id": tool_use_id,
"name": tool_name,
"arguments": "",
"index": block_index,
}
@@ -1106,9 +1189,9 @@ class AnthropicCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
tool_call={
"id": block.id,
"id": tool_use_id,
"function": {
"name": block.name,
"name": tool_name,
"arguments": "",
},
"type": "function",
@@ -1177,10 +1260,12 @@ class AnthropicCompletion(BaseLLM):
return structured_data
for block in final_message.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_name(block) == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_input(block)
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1194,11 +1279,7 @@ class AnthropicCompletion(BaseLLM):
return structured_data
if final_message.content:
tool_uses = [
block
for block in final_message.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(final_message.content))
if tool_uses:
if not available_functions:
@@ -1229,7 +1310,7 @@ class AnthropicCompletion(BaseLLM):
def _execute_tools_and_collect_results(
self,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[_AnthropicToolUseBlock],
available_functions: dict[str, Any],
from_task: Any | None = None,
from_agent: Any | None = None,
@@ -1248,12 +1329,12 @@ class AnthropicCompletion(BaseLLM):
tool_results = []
for tool_use in tool_uses:
function_name = tool_use.name
function_args = tool_use.input
function_name = _tool_use_name(tool_use)
function_args = _tool_use_input(tool_use)
result = self._handle_tool_execution(
function_name=function_name,
function_args=cast(dict[str, Any], function_args),
function_args=function_args,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
@@ -1261,7 +1342,7 @@ class AnthropicCompletion(BaseLLM):
tool_result = {
"type": "tool_result",
"tool_use_id": tool_use.id,
"tool_use_id": _tool_use_id(tool_use),
"content": str(result)
if result is not None
else "Tool execution completed",
@@ -1272,7 +1353,7 @@ class AnthropicCompletion(BaseLLM):
def _execute_first_tool(
self,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[_AnthropicToolUseBlock],
available_functions: dict[str, Any],
from_task: Any | None = None,
from_agent: Any | None = None,
@@ -1293,8 +1374,8 @@ class AnthropicCompletion(BaseLLM):
The result of the first tool execution, or None if execution failed
"""
tool_use = tool_uses[0]
function_name = tool_use.name
function_args = cast(dict[str, Any], tool_use.input)
function_name = _tool_use_name(tool_use)
function_args = _tool_use_input(tool_use)
return self._handle_tool_execution(
function_name=function_name,
@@ -1308,7 +1389,7 @@ class AnthropicCompletion(BaseLLM):
def _handle_tool_use_conversation(
self,
initial_response: Message | BetaMessage,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[_AnthropicToolUseBlock],
params: dict[str, Any],
available_functions: dict[str, Any],
from_task: Any | None = None,
@@ -1335,13 +1416,13 @@ class AnthropicCompletion(BaseLLM):
thinking_block = self._extract_thinking_block(block)
if thinking_block:
assistant_content.append(thinking_block)
elif block.type == "tool_use":
elif _is_tool_use_block(block):
assistant_content.append(
{
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
"id": _tool_use_id(block),
"name": _tool_use_name(block),
"input": _tool_use_input(block),
}
)
elif hasattr(block, "text"):
@@ -1494,10 +1575,12 @@ class AnthropicCompletion(BaseLLM):
else:
for block in response.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_name(block) == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_input(block)
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1510,13 +1593,9 @@ class AnthropicCompletion(BaseLLM):
)
return structured_data
# Handle both ToolUseBlock (regular API) and BetaToolUseBlock (beta API features)
# Handle Anthropic tool-use blocks across stable, beta, and preview SDK shapes.
if response.content:
tool_uses = [
block
for block in response.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(response.content))
if tool_uses:
if not available_functions:
@@ -1629,11 +1708,13 @@ class AnthropicCompletion(BaseLLM):
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
if _is_tool_use_block(block):
block_index = event.index
tool_use_id = _tool_use_id(block)
tool_name = _tool_use_name(block)
current_tool_calls[block_index] = {
"id": block.id,
"name": block.name,
"id": tool_use_id,
"name": tool_name,
"arguments": "",
"index": block_index,
}
@@ -1642,9 +1723,9 @@ class AnthropicCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
tool_call={
"id": block.id,
"id": tool_use_id,
"function": {
"name": block.name,
"name": tool_name,
"arguments": "",
},
"type": "function",
@@ -1703,10 +1784,12 @@ class AnthropicCompletion(BaseLLM):
return structured_data
for block in final_message.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_name(block) == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_input(block)
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1720,11 +1803,7 @@ class AnthropicCompletion(BaseLLM):
return structured_data
if final_message.content:
tool_uses = [
block
for block in final_message.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(final_message.content))
if tool_uses:
if not available_functions:
@@ -1754,7 +1833,7 @@ class AnthropicCompletion(BaseLLM):
async def _ahandle_tool_use_conversation(
self,
initial_response: Message | BetaMessage,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[_AnthropicToolUseBlock],
params: dict[str, Any],
available_functions: dict[str, Any],
from_task: Any | None = None,

View File

@@ -1,7 +1,7 @@
import os
import sys
import types
from unittest.mock import patch, MagicMock
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from crewai.llm import LLM
@@ -1358,6 +1358,248 @@ _MANY_TOOLS = [
]
def _dict_tool_use_response():
mock_response = MagicMock()
mock_response.content = [
{
"type": "tool_use",
"id": "toolu_123",
"name": "search_web",
"input": {"query": "CrewAI"},
}
]
mock_response.usage = MagicMock(input_tokens=10, output_tokens=2)
mock_response.stop_reason = "tool_use"
mock_response.id = "msg_123"
return mock_response
class _SyncAnthropicStream:
def __init__(self, events, final_message):
self.events = events
self.final_message = final_message
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def __iter__(self):
return iter(self.events)
def get_final_message(self):
return self.final_message
class _AsyncAnthropicStream:
def __init__(self, events, final_message):
self.events = list(events)
self.final_message = final_message
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
def __aiter__(self):
self._iter = iter(self.events)
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
async def get_final_message(self):
return self.final_message
def _dict_tool_use_stream_events():
return [
types.SimpleNamespace(
type="content_block_start",
index=0,
content_block={
"type": "tool_use",
"id": "toolu_123",
"name": "search_web",
"input": {},
},
),
types.SimpleNamespace(
type="content_block_delta",
index=0,
delta=types.SimpleNamespace(
type="input_json_delta",
partial_json='{"query":"CrewAI"}',
),
),
]
def test_anthropic_tool_use_dict_blocks_are_returned_as_tool_calls():
"""Preview Anthropic models may return dict-shaped tool_use blocks."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
def test_anthropic_dict_tool_use_blocks_require_id():
"""Incomplete dict-shaped tool_use blocks are not valid tool calls."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
del mock_response.content[0]["id"]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == ""
def test_anthropic_object_tool_use_blocks_require_id():
"""Incomplete object-shaped tool_use blocks are not valid tool calls."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = MagicMock()
mock_response.content = [
types.SimpleNamespace(
type="tool_use",
name="search_web",
input={"query": "CrewAI"},
)
]
mock_response.usage = MagicMock(input_tokens=10, output_tokens=2)
mock_response.stop_reason = "tool_use"
mock_response.id = "msg_123"
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == ""
@pytest.mark.asyncio
async def test_anthropic_acall_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
llm._async_client = mock_client
result = await llm.acall("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
def test_anthropic_streaming_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5", stream=True)
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.stream.return_value = _SyncAnthropicStream(
_dict_tool_use_stream_events(), mock_response
)
llm._client = mock_client
llm._emit_stream_chunk_event = MagicMock()
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
assert any(
call.kwargs.get("tool_call", {}).get("id") == "toolu_123"
for call in llm._emit_stream_chunk_event.call_args_list
)
@pytest.mark.asyncio
async def test_anthropic_async_streaming_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5", stream=True)
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.stream.return_value = _AsyncAnthropicStream(
_dict_tool_use_stream_events(), mock_response
)
llm._async_client = mock_client
llm._emit_stream_chunk_event = MagicMock()
result = await llm.acall("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
assert any(
call.kwargs.get("tool_call", {}).get("id") == "toolu_123"
for call in llm._emit_stream_chunk_event.call_args_list
)
def test_anthropic_dict_tool_use_blocks_execute_available_function():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call(
"Search for CrewAI",
tools=_MANY_TOOLS,
available_functions={"search_web": lambda query: f"found {query}"},
)
assert result == "found CrewAI"
def test_anthropic_dict_tool_use_blocks_work_in_follow_up_conversation():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
initial_response = _dict_tool_use_response()
final_response = MagicMock()
final_response.content = [types.SimpleNamespace(text="Final answer")]
final_response.usage = MagicMock(input_tokens=4, output_tokens=3)
final_response.stop_reason = "end_turn"
final_response.id = "msg_final"
mock_client = MagicMock()
mock_client.messages.create.return_value = final_response
llm._client = mock_client
result = llm._handle_tool_use_conversation(
initial_response,
initial_response.content,
params={"messages": []},
available_functions={"search_web": lambda query: f"found {query}"},
)
assert result == "Final answer"
@pytest.mark.vcr()
def test_tool_search_discovers_and_calls_tool():
"""Tool search should discover the right tool and return a tool_use block."""

View File

@@ -172,7 +172,7 @@ info = "Commits must follow Conventional Commits 1.0.0."
[tool.uv]
exclude-newer = "3 days"
# These security fixes are newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-24T00:00:00Z", msgpack = "2026-06-20T00:00:00Z", pydantic-settings = "2026-06-20T00:00:00Z", langsmith = "2026-06-20T00:00:00Z" }
exclude-newer-package = { pypdf = "2026-06-24T00:00:00Z", msgpack = "2026-06-20T00:00:00Z", pydantic-settings = "2026-06-20T00:00:00Z", langsmith = "2026-06-20T00:00:00Z", gitpython = "2026-07-24T00:00:00Z" }
# composio-core pins rich<14 but textual requires rich>=14.
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
@@ -187,7 +187,9 @@ exclude-newer-package = { pypdf = "2026-06-24T00:00:00Z", msgpack = "2026-06-20T
# uv <0.11.15 has GHSA-4gg8-gxpx-9rph (and earlier GHSA-pjjw-68hj-v9mw); force 0.11.15+.
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
# gitpython <3.1.51 has GHSA-2f96-g7mh-g2hx, GHSA-v396-v7q4-x2qj, and GHSA-956x-8gvw-wg5v; force 3.1.51+.
# gitpython <3.1.51 has GHSA-2f96-g7mh-g2hx, GHSA-v396-v7q4-x2qj, and GHSA-956x-8gvw-wg5v.
# gitpython <=3.1.51 has GHSA-rwj8-pgh3-r573; fixed in 3.1.52.
# gitpython 3.1.52 has GHSA-3rp5-jjmw-4wv2, GHSA-fjr4-x663-mwxc, GHSA-6p8h-3wgx-97gf, and GHSA-r9mr-m37c-5fr3; force 3.1.55+.
# pyasn1 <0.6.4 has GHSA-8ppf-4f7h-5ppj and GHSA-hm4w-wwcw-mr6r; force 0.6.4+.
# urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+.
# langsmith <0.8.18 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure)
@@ -216,7 +218,7 @@ override-dependencies = [
"pypdf>=6.14.2,<7",
"uv>=0.11.15,<1",
"python-multipart>=0.0.27,<1",
"gitpython>=3.1.51,<4",
"gitpython>=3.1.55,<4",
"pyasn1>=0.6.4",
"langsmith>=0.8.18,<1",
"authlib>=1.6.12",

13
uv.lock generated
View File

@@ -13,12 +13,13 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-07-21T05:40:28.001333135Z"
exclude-newer = "2026-07-21T18:21:00.467584933Z"
exclude-newer-span = "P3D"
[options.exclude-newer-package]
msgpack = "2026-06-20T00:00:00Z"
langsmith = "2026-06-20T00:00:00Z"
gitpython = "2026-07-24T00:00:00Z"
pypdf = "2026-06-24T00:00:00Z"
pydantic-settings = "2026-06-20T00:00:00Z"
@@ -36,7 +37,7 @@ overrides = [
{ name = "authlib", specifier = ">=1.6.12" },
{ name = "cryptography", specifier = ">=46.0.7" },
{ name = "docling-core", extras = ["chunking"], specifier = ">=2.74.1" },
{ name = "gitpython", specifier = ">=3.1.51,<4" },
{ name = "gitpython", specifier = ">=3.1.55,<4" },
{ name = "langchain-core", specifier = ">=1.3.3,<2" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2,<2" },
{ name = "langsmith", specifier = ">=0.8.18,<1" },
@@ -1755,7 +1756,7 @@ requires-dist = [
{ name = "e2b-code-interpreter", marker = "extra == 'e2b'", specifier = "~=2.6.0" },
{ name = "exa-py", marker = "extra == 'exa-py'", specifier = ">=1.8.7" },
{ name = "firecrawl-py", marker = "extra == 'firecrawl-py'", specifier = ">=1.8.0" },
{ name = "gitpython", marker = "extra == 'github'", specifier = ">=3.1.51,<4" },
{ name = "gitpython", marker = "extra == 'github'", specifier = ">=3.1.55,<4" },
{ name = "hyperbrowser", marker = "extra == 'hyperbrowser'", specifier = ">=0.18.0" },
{ name = "langchain-apify", marker = "extra == 'apify'", specifier = ">=0.1.2,<1.0.0" },
{ name = "linkup-sdk", marker = "extra == 'linkup-sdk'", specifier = ">=0.2.2" },
@@ -2767,14 +2768,14 @@ wheels = [
[[package]]
name = "gitpython"
version = "3.1.52"
version = "3.1.55"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/fd/df0bafa4eb5ea2f51e1adee9f7a94c8e62c5d180e65117045dfca3439c8a/gitpython-3.1.52.tar.gz", hash = "sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e", size = 223726, upload-time = "2026-07-16T03:15:59.599Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/ab/ba0d29f2fa2277ed6256b2ac09003494045355f3a10bf32f351761287870/gitpython-3.1.55.tar.gz", hash = "sha256:781e3b1624dad81b24e9524bf0297b69786a0706db2cbceec1e2b05c38e5152f", size = 225071, upload-time = "2026-07-23T02:52:43.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/90/04dff7c1e176bb1c3011ef1647393d368790da710d8dde1cdcfad301f45a/gitpython-3.1.52-py3-none-any.whl", hash = "sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a", size = 215366, upload-time = "2026-07-16T03:15:58.239Z" },
{ url = "https://files.pythonhosted.org/packages/20/6a/d3b8208d2f8aac66abe8ccc1c23fa2c89464ec42cc71a601e95d05902428/gitpython-3.1.55-py3-none-any.whl", hash = "sha256:7c9ec1e69c158c081632ab35c41471e302c96db2ae42165036a5d2403378812e", size = 216590, upload-time = "2026-07-23T02:52:41.932Z" },
]
[[package]]