diff --git a/docs/docs/concepts/plugins.mdx b/docs/docs/concepts/plugins.mdx index 192d7bd1f..608904f01 100644 --- a/docs/docs/concepts/plugins.mdx +++ b/docs/docs/concepts/plugins.mdx @@ -659,7 +659,7 @@ async def cap_tokens(payload, ctx): **Fires:** Before invoking a tool from LLM output. -**Payload fields:** `model_tool_call` (contains `name`, `args`, `callable`) +**Payload fields:** `model_tool_call` (contains `name`, `args`, `callable`), `is_control_flow` **Writable fields:** `model_tool_call` @@ -675,11 +675,15 @@ async def enforce_tool_allowlist(payload, ctx): return block(f"Tool '{payload.model_tool_call.name}' not permitted", code="TOOL_NOT_ALLOWED") ``` + +The payload includes an `is_control_flow` field that is `True` for framework control-flow tools (e.g. the ReAct loop's `final_answer`). Allowlist plugins should check this field to avoid blocking internal tools. See [Control-flow tools](#control-flow-tools) for the recommended pattern. + + #### `tool_post_invoke` **Fires:** After tool execution completes. -**Payload fields:** `model_tool_call`, `tool_output`, `tool_message`, `execution_time_ms`, `success`, `error` +**Payload fields:** `model_tool_call`, `tool_output`, `tool_message`, `execution_time_ms`, `success`, `error`, `is_control_flow` **Writable fields:** `tool_output` @@ -811,13 +815,15 @@ The `tool_pre_invoke` and `tool_post_invoke` hooks give you fine-grained control ### Tool allow-listing -Block any tool not on an explicit approved list: +Block any tool not on an explicit approved list. The `is_control_flow` guard ensures framework tools like `final_answer` are not blocked: ```python ALLOWED_TOOLS = frozenset({"get_weather", "calculator"}) @hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5) async def enforce_tool_allowlist(payload, ctx): + if payload.is_control_flow: + return # framework control-flow tools are exempt tool_name = payload.model_tool_call.name if tool_name not in ALLOWED_TOOLS: return block(f"Tool '{tool_name}' is not permitted", code="TOOL_NOT_ALLOWED") @@ -879,6 +885,43 @@ with start_session(plugins=[tool_security]) as m: See the [full tool hooks example](https://github.com/generative-computing/mellea/blob/main/docs/examples/plugins/tool_hooks.py). +### Control-flow tools + +Mellea's frameworks use internal tools for control flow. For example, the [ReAct loop](../reference/glossary#react) uses a `final_answer` tool to signal that the agent has finished reasoning. These tools flow through the same invocation path as user-defined tools — hooks always fire for them — but the payload carries an `is_control_flow` flag so each plugin can decide its own policy. + +The recommended pattern for allowlist plugins is to skip control-flow tools explicitly: + +```python +ALLOWED_TOOLS = frozenset({"get_weather", "calculator"}) + +@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5) +async def enforce_tool_allowlist(payload, ctx): + if payload.is_control_flow: + return # framework control-flow tools are exempt + if payload.model_tool_call.name not in ALLOWED_TOOLS: + return block(f"Tool '{payload.model_tool_call.name}' not permitted") +``` + +Logging and telemetry plugins typically do **not** check this flag — they observe all tool calls including control-flow tools: + +```python +@hook(HookType.TOOL_POST_INVOKE, mode=PluginMode.FIRE_AND_FORGET) +async def log_all_tools(payload, ctx): + logger.info("tool=%s control_flow=%s ms=%d", payload.model_tool_call.name, + payload.is_control_flow, payload.execution_time_ms) +``` + +#### Querying the registry + +Use `is_internal_tool()` to check whether a tool name is a known control-flow tool: + +```python +from mellea.plugins import is_internal_tool + +is_internal_tool("final_answer") # True +is_internal_tool("get_weather") # False +``` + --- ## Patterns and best practices @@ -972,18 +1015,19 @@ All public symbols are available from a single import: ```python from mellea.plugins import ( - HookType, # Enum of all hook types (e.g., GENERATION_PRE_CALL) - Plugin, # Base class for class-based plugins - PluginMode, # Execution mode enum (SEQUENTIAL, TRANSFORM, AUDIT, CONCURRENT, FIRE_AND_FORGET) - PluginResult, # Return type for hooks that modify or block - PluginSet, # Named group of hooks/plugins for composition - PluginViolationError,# Exception raised when a hook blocks execution - block, # Helper to create a blocking PluginResult - hook, # Decorator to register an async function as a hook handler - modify, # Helper to create a modifying PluginResult - plugin_scope, # Context manager for with-block scoped activation - register, # Register hooks/plugins globally or per-session - unregister, # Remove globally-registered hooks/plugins + HookType, # Enum of all hook types (e.g., GENERATION_PRE_CALL) + Plugin, # Base class for class-based plugins + PluginMode, # Execution mode enum (SEQUENTIAL, TRANSFORM, ...) + PluginResult, # Return type for hooks that modify or block + PluginSet, # Named group of hooks/plugins for composition + PluginViolationError, # Exception raised when a hook blocks execution + block, # Helper to create a blocking PluginResult + hook, # Decorator to register an async function as a hook handler + is_internal_tool, # Check if a tool is a framework control-flow tool + modify, # Helper to create a modifying PluginResult + plugin_scope, # Context manager for with-block scoped activation + register, # Register hooks/plugins globally or per-session + unregister, # Remove globally-registered hooks/plugins ) ``` @@ -997,6 +1041,7 @@ from mellea.plugins import ( | `plugin_scope(*items)` | Context manager that registers on enter, deregisters on exit | | `block(reason, *, code, details)` | Create a blocking `PluginResult` | | `modify(payload, **field_updates)` | Create a modifying `PluginResult` via `model_copy` | +| `is_internal_tool(tool_name)` | Returns `True` if the tool is a framework control-flow tool (e.g. `final_answer`) | | `HookType` | Enum with all 18 hook types | | `PluginMode` | Enum: `SEQUENTIAL`, `TRANSFORM`, `AUDIT`, `CONCURRENT`, `FIRE_AND_FORGET` | | `PluginResult` | Typed result with `continue_processing`, `modified_payload`, and `violation` | diff --git a/docs/examples/plugins/tool_hooks.py b/docs/examples/plugins/tool_hooks.py index 3b574ba75..96ec518ba 100644 --- a/docs/examples/plugins/tool_hooks.py +++ b/docs/examples/plugins/tool_hooks.py @@ -150,6 +150,8 @@ def parse_factor(): @hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5) async def enforce_tool_allowlist(payload, _): """Block any tool not on the explicit allow list.""" + if payload.is_control_flow: + return # framework control-flow tools (e.g. final_answer) are exempt tool_name = payload.model_tool_call.name if tool_name not in ALLOWED_TOOLS: log.warning( diff --git a/mellea/plugins/__init__.py b/mellea/plugins/__init__.py index f5433fdd8..c4a7aa6aa 100644 --- a/mellea/plugins/__init__.py +++ b/mellea/plugins/__init__.py @@ -9,6 +9,7 @@ from .base import Plugin, PluginResult, PluginViolationError from .decorators import hook +from .manager import is_internal_tool from .pluginset import PluginSet from .registry import block, modify, plugin_scope, register, unregister from .types import HookType, PluginMode @@ -22,6 +23,7 @@ "PluginViolationError", "block", "hook", + "is_internal_tool", "modify", "plugin_scope", "register", diff --git a/mellea/plugins/hooks/tool.py b/mellea/plugins/hooks/tool.py index bc92d430e..8bba52ad3 100644 --- a/mellea/plugins/hooks/tool.py +++ b/mellea/plugins/hooks/tool.py @@ -13,9 +13,13 @@ class ToolPreInvokePayload(MelleaBasePayload): Attributes: model_tool_call: The ``ModelToolCall`` about to be executed (writable — plugins may modify arguments or swap the tool entirely). + is_control_flow: ``True`` when this tool is used for framework control + flow (e.g. ``final_answer`` in ReAct) rather than data processing. + Plugins should check this field to decide whether to act. """ model_tool_call: Any = None + is_control_flow: bool = False class ToolPostInvokePayload(MelleaBasePayload): @@ -29,6 +33,9 @@ class ToolPostInvokePayload(MelleaBasePayload): execution_time_ms: Wall-clock time of the tool execution in milliseconds. success: ``True`` if the tool executed without raising an exception. error: The ``Exception`` raised during execution, or ``None`` on success. + is_control_flow: ``True`` when this tool is used for framework control + flow (e.g. ``final_answer`` in ReAct) rather than data processing. + Plugins should check this field to decide whether to act. """ model_tool_call: Any = None @@ -37,3 +44,4 @@ class ToolPostInvokePayload(MelleaBasePayload): execution_time_ms: int = 0 success: bool = True error: Any = None + is_control_flow: bool = False diff --git a/mellea/plugins/manager.py b/mellea/plugins/manager.py index e196a0a25..ce2859d38 100644 --- a/mellea/plugins/manager.py +++ b/mellea/plugins/manager.py @@ -29,6 +29,10 @@ _pending_background_results: list[Any] = [] _collect_background_results: bool = False # opt-in; only tests enable this +# Framework control-flow tool names (e.g. loop terminators). +# These are flagged on the payload so plugins can decide per-tool policy. +_INTERNAL_TOOL_NAMES: frozenset[str] = frozenset({"final_answer"}) + DEFAULT_PLUGIN_TIMEOUT: int = 5 # seconds DEFAULT_HOOK_POLICY: Literal["allow"] | Literal["deny"] = "deny" @@ -88,6 +92,18 @@ def has_plugins(hook_type: HookType | None = None) -> bool: return True +def is_internal_tool(tool_name: str) -> bool: + """Return whether the given tool name is a framework-internal tool. + + Args: + tool_name: Name of the tool to check. + + Returns: + ``True`` if the tool is in the internal tools registry. + """ + return tool_name in _INTERNAL_TOOL_NAMES + + def get_plugin_manager() -> Any | None: """Return the initialized PluginManager, or ``None`` if plugins are not configured. diff --git a/mellea/stdlib/functional.py b/mellea/stdlib/functional.py index 849a72cf8..a281e7c3f 100644 --- a/mellea/stdlib/functional.py +++ b/mellea/stdlib/functional.py @@ -30,7 +30,7 @@ ) from ..helpers import _run_async_in_thread from ..plugins.hooks.tool import ToolPostInvokePayload, ToolPreInvokePayload -from ..plugins.manager import has_plugins, invoke_hook +from ..plugins.manager import has_plugins, invoke_hook, is_internal_tool from ..plugins.types import HookType from ..telemetry import set_span_attribute, trace_application from .components import ( @@ -1287,9 +1287,13 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM return outputs for name, tool in tool_calls.items(): + control_flow = is_internal_tool(name) + # --- tool_pre_invoke --- if has_plugins(HookType.TOOL_PRE_INVOKE): - pre_payload = ToolPreInvokePayload(model_tool_call=tool) + pre_payload = ToolPreInvokePayload( + model_tool_call=tool, is_control_flow=control_flow + ) _, pre_payload = await invoke_hook( HookType.TOOL_PRE_INVOKE, pre_payload, backend=backend ) @@ -1335,6 +1339,7 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM execution_time_ms=latency_ms, success=success, error=error, + is_control_flow=control_flow, ) _, post_payload = await invoke_hook( HookType.TOOL_POST_INVOKE, post_payload, backend=backend diff --git a/pyproject.toml b/pyproject.toml index 159bb61ab..4e5f877d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ switch = [ backends = ["mellea[watsonx,hf,litellm]"] hooks = [ - "cpex>=0.1.0.dev12; python_version >= '3.11'", + "cpex>=0.1.0", "grpcio>=1.78.0", ] diff --git a/test/plugins/test_internal_tool_hook_skip.py b/test/plugins/test_internal_tool_hook_skip.py new file mode 100644 index 000000000..6f7836d4e --- /dev/null +++ b/test/plugins/test_internal_tool_hook_skip.py @@ -0,0 +1,204 @@ +"""Tests for control-flow tool signalling on tool hook payloads. + +Verifies that TOOL_PRE_INVOKE and TOOL_POST_INVOKE hooks always fire for all +tools (including framework-internal ones like ``final_answer``), and that the +``is_control_flow`` field is correctly populated so plugins can decide their +own policy. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("cpex.framework") + +from mellea.core.base import AbstractMelleaTool, ModelOutputThunk, ModelToolCall +from mellea.plugins import block, hook, is_internal_tool, register +from mellea.plugins.manager import shutdown_plugins +from mellea.plugins.types import HookType, PluginMode +from mellea.stdlib.functional import _acall_tools + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _RecordingTool(AbstractMelleaTool): + """A tool that records invocations.""" + + def __init__(self, name: str = "test_tool") -> None: + self.name = name + self.calls: list[dict[str, Any]] = [] + + def run(self, **kwargs: Any) -> str: + self.calls.append(dict(kwargs)) + return f"result from {self.name}" + + @property + def as_json_tool(self) -> dict[str, Any]: + return {"name": self.name, "description": "recording tool", "parameters": {}} + + +def _make_result(*tool_calls: ModelToolCall) -> ModelOutputThunk: + """Wrap one or more ModelToolCalls in a minimal ModelOutputThunk.""" + mot = MagicMock(spec=ModelOutputThunk) + mot.tool_calls = {tc.name: tc for tc in tool_calls} + return mot + + +# --------------------------------------------------------------------------- +# Tests — is_internal_tool +# --------------------------------------------------------------------------- + + +class TestIsInternalTool: + def test_recognizes_final_answer(self) -> None: + assert is_internal_tool("final_answer") is True + + def test_rejects_user_tool(self) -> None: + assert is_internal_tool("search") is False + assert is_internal_tool("get_weather") is False + + +# --------------------------------------------------------------------------- +# Tests — hooks always fire, payload carries is_control_flow +# --------------------------------------------------------------------------- + + +class TestControlFlowPayloadField: + async def test_pre_hook_fires_for_internal_tool(self) -> None: + """TOOL_PRE_INVOKE fires for final_answer with is_control_flow=True.""" + tool = _RecordingTool("final_answer") + tc = ModelToolCall(name="final_answer", func=tool, args={"answer": "42"}) + result = _make_result(tc) + + captured: list[Any] = [] + + @hook(HookType.TOOL_PRE_INVOKE) + async def spy(payload, *_): + captured.append(payload) + + register(spy) + + await _acall_tools(result, MagicMock()) + + assert len(captured) == 1 + assert captured[0].model_tool_call.name == "final_answer" + assert captured[0].is_control_flow is True + + async def test_post_hook_fires_for_internal_tool(self) -> None: + """TOOL_POST_INVOKE fires for final_answer with is_control_flow=True.""" + tool = _RecordingTool("final_answer") + tc = ModelToolCall(name="final_answer", func=tool, args={"answer": "42"}) + result = _make_result(tc) + + captured: list[Any] = [] + + @hook(HookType.TOOL_POST_INVOKE) + async def spy(payload, *_): + captured.append(payload) + + register(spy) + + await _acall_tools(result, MagicMock()) + + assert len(captured) == 1 + assert captured[0].is_control_flow is True + + async def test_user_tool_has_control_flow_false(self) -> None: + """User tools get is_control_flow=False.""" + tool = _RecordingTool("search") + tc = ModelToolCall(name="search", func=tool, args={}) + result = _make_result(tc) + + captured: list[Any] = [] + + @hook(HookType.TOOL_PRE_INVOKE) + async def spy(payload, *_): + captured.append(payload) + + register(spy) + + await _acall_tools(result, MagicMock()) + + assert len(captured) == 1 + assert captured[0].is_control_flow is False + + async def test_mixed_batch_sets_flag_correctly(self) -> None: + """In a batch, each tool gets the correct is_control_flow value.""" + internal_tool = _RecordingTool("final_answer") + user_tool = _RecordingTool("search") + tc_internal = ModelToolCall( + name="final_answer", func=internal_tool, args={"answer": "done"} + ) + tc_user = ModelToolCall(name="search", func=user_tool, args={}) + result = _make_result(tc_internal, tc_user) + + captured: list[Any] = [] + + @hook(HookType.TOOL_PRE_INVOKE) + async def spy(payload, *_): + captured.append(payload) + + register(spy) + + await _acall_tools(result, MagicMock()) + + assert len(captured) == 2 + by_name = {p.model_tool_call.name: p for p in captured} + assert by_name["final_answer"].is_control_flow is True + assert by_name["search"].is_control_flow is False + + +# --------------------------------------------------------------------------- +# Tests — plugin pattern: allowlist that skips control-flow tools +# --------------------------------------------------------------------------- + + +class TestAllowlistPluginPattern: + async def test_allowlist_does_not_block_control_flow_tool(self) -> None: + """An allowlist plugin using is_control_flow guard does not block final_answer.""" + allowed_tools = frozenset({"search"}) + + @hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5) + async def enforce_allowlist(payload, _): + if payload.is_control_flow: + return + if payload.model_tool_call.name not in allowed_tools: + return block(f"Tool '{payload.model_tool_call.name}' not permitted") + + register(enforce_allowlist) + + internal_tool = _RecordingTool("final_answer") + tc = ModelToolCall( + name="final_answer", func=internal_tool, args={"answer": "ok"} + ) + result = _make_result(tc) + + msgs = await _acall_tools(result, MagicMock()) + assert len(msgs) == 1 + + async def test_allowlist_still_blocks_unknown_user_tools(self) -> None: + """The allowlist pattern still blocks non-allowed user tools.""" + from mellea.plugins.base import PluginViolationError + + allowed_tools = frozenset({"search"}) + + @hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5) + async def enforce_allowlist(payload, _): + if payload.is_control_flow: + return + if payload.model_tool_call.name not in allowed_tools: + return block(f"Tool '{payload.model_tool_call.name}' not permitted") + + register(enforce_allowlist) + + unknown_tool = _RecordingTool("hack_system") + tc = ModelToolCall(name="hack_system", func=unknown_tool, args={}) + result = _make_result(tc) + + with pytest.raises(PluginViolationError, match="not permitted"): + await _acall_tools(result, MagicMock()) diff --git a/uv.lock b/uv.lock index 0e051f419..f851e3b18 100644 --- a/uv.lock +++ b/uv.lock @@ -883,7 +883,7 @@ toml = [ [[package]] name = "cpex" -version = "0.1.0.dev12" +version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, @@ -899,9 +899,9 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/f6/d5a194b338b3d55b1b9b8619baafa504ae8146168cf4b91fcefa95811a16/cpex-0.1.0.dev12.tar.gz", hash = "sha256:9fb08e0fa27236747c26c841260951a83252029c0e55a7550c65a060473f200c", size = 3475629, upload-time = "2026-04-23T17:34:14.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/73/98eb3322c63fe8654e3db87b13557aff75eaeecf35aabb129901e5c7704e/cpex-0.1.0.tar.gz", hash = "sha256:d67fdb2892bb9c683d42c716be46d926fbb01b9f4c3d85d2cdf0869d7a0f2d0e", size = 1211837, upload-time = "2026-05-05T19:17:15.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ed/f70537bd8adbf1f847a703e02c3abd2cdc53dfa87e44977aa25dd163774b/cpex-0.1.0.dev12-py3-none-any.whl", hash = "sha256:5c10688b6f7ca8c3673fce9dfd94d0b3a348e0e63566546ced5068574a38403e", size = 236654, upload-time = "2026-04-23T17:34:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/e2/15/c1a98af9dca602846373a07dd09b3944f6e2fceee132df58772acc057d66/cpex-0.1.0-py3-none-any.whl", hash = "sha256:890db8077e05aedf8236b1ff140f39b677944a751afe57de6538faf884d64d4a", size = 241015, upload-time = "2026-05-05T19:17:13.614Z" }, ] [[package]] @@ -1688,7 +1688,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, + { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, @@ -1696,7 +1698,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, @@ -1704,7 +1708,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, @@ -1712,7 +1718,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, + { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, @@ -1720,7 +1728,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, @@ -3558,7 +3568,7 @@ typecheck = [ requires-dist = [ { name = "accelerate", marker = "extra == 'hf'", specifier = ">=1.9.0" }, { name = "boto3", marker = "extra == 'litellm'" }, - { name = "cpex", marker = "python_full_version >= '3.11' and extra == 'hooks'", specifier = ">=0.1.0.dev12" }, + { name = "cpex", marker = "extra == 'hooks'", specifier = ">=0.1.0" }, { name = "datasets", marker = "extra == 'hf'", specifier = ">=4.0.0" }, { name = "docling", marker = "extra == 'docling'", specifier = ">=2.45.0" }, { name = "elasticsearch", marker = "extra == 'granite-retriever'", specifier = ">=8.0.0,<9.0.0" },