From 14b1b84a47a7ace0c2ee4f1ba2c74829c95d100a Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 9 May 2026 19:25:00 +0200 Subject: [PATCH] feat(cerbero): HTTP client with bearer + bot-tag + retry Client minimale verso Cerbero MCP unified server con header Authorization Bearer e X-Bot-Tag su ogni richiesta. Retry esponenziale solo su ConnectionError (non su 4xx). 4xx/5xx -> RuntimeError. Switch da httpx a requests per allineamento con libreria mock `responses` (httpx richiederebbe respx). httpx resta in deps per usi futuri. Aggiunto types-requests ai dev deps per mypy strict. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 + src/multi_swarm/cerbero/__init__.py | 0 src/multi_swarm/cerbero/client.py | 60 +++++++++++++++++++++++++++++ tests/unit/test_cerbero_client.py | 37 ++++++++++++++++++ uv.lock | 16 ++++++++ 5 files changed, 115 insertions(+) create mode 100644 src/multi_swarm/cerbero/__init__.py create mode 100644 src/multi_swarm/cerbero/client.py create mode 100644 tests/unit/test_cerbero_client.py diff --git a/pyproject.toml b/pyproject.toml index c8311da..f9f2112 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "anthropic>=0.39", "openai>=1.55", "httpx>=0.28", + "requests>=2.32", "tenacity>=9.0", "pyyaml>=6.0", "streamlit>=1.40", @@ -31,6 +32,7 @@ dev = [ "responses>=0.25", "ruff>=0.7", "mypy>=1.13", + "types-requests>=2.32", ] [build-system] diff --git a/src/multi_swarm/cerbero/__init__.py b/src/multi_swarm/cerbero/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/multi_swarm/cerbero/client.py b/src/multi_swarm/cerbero/client.py new file mode 100644 index 0000000..b03a7ed --- /dev/null +++ b/src/multi_swarm/cerbero/client.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from types import TracebackType +from typing import Any + +import requests +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential + + +class CerberoClient: + """Client HTTP minimale verso Cerbero MCP unified server.""" + + def __init__( + self, + base_url: str, + token: str, + bot_tag: str, + timeout_seconds: float = 10.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self.bot_tag = bot_tag + self.timeout_seconds = timeout_seconds + self._session = requests.Session() + self._session.headers.update( + { + "Authorization": f"Bearer {token}", + "X-Bot-Tag": bot_tag, + "Content-Type": "application/json", + } + ) + + def close(self) -> None: + self._session.close() + + def __enter__(self) -> CerberoClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=0.5, min=0.5, max=4.0), + retry=retry_if_exception_type(requests.ConnectionError), + reraise=True, + ) + def call_tool(self, exchange: str, tool: str, args: dict[str, Any]) -> Any: + url = f"{self.base_url}/mcp-{exchange}/tools/{tool}" + resp = self._session.post(url, json=args, timeout=self.timeout_seconds) + if resp.status_code >= 400: + raise RuntimeError( + f"Cerbero {exchange}/{tool} returned {resp.status_code}: {resp.text}" + ) + return resp.json() diff --git a/tests/unit/test_cerbero_client.py b/tests/unit/test_cerbero_client.py new file mode 100644 index 0000000..3027a6d --- /dev/null +++ b/tests/unit/test_cerbero_client.py @@ -0,0 +1,37 @@ +import pytest +import responses + +from multi_swarm.cerbero.client import CerberoClient + + +@responses.activate +def test_call_tool_passes_bearer_and_bot_tag() -> None: + responses.add( + responses.POST, + "http://test:9000/mcp-deribit/tools/get_iv_rank", + json={"iv_rank": 0.42}, + status=200, + ) + client = CerberoClient( + base_url="http://test:9000", token="tok-xyz", bot_tag="swarm-poc-phase1" + ) + result = client.call_tool("deribit", "get_iv_rank", {"symbol": "BTC-PERPETUAL"}) + assert result == {"iv_rank": 0.42} + req = responses.calls[0].request + assert req.headers["Authorization"] == "Bearer tok-xyz" + assert req.headers["X-Bot-Tag"] == "swarm-poc-phase1" + + +@responses.activate +def test_call_tool_raises_on_error() -> None: + responses.add( + responses.POST, + "http://test:9000/mcp-deribit/tools/get_iv_rank", + json={"error": "bad"}, + status=400, + ) + client = CerberoClient( + base_url="http://test:9000", token="tok-xyz", bot_tag="swarm-poc-phase1" + ) + with pytest.raises(RuntimeError): + client.call_tool("deribit", "get_iv_rank", {}) diff --git a/uv.lock b/uv.lock index c805279..0ea5607 100644 --- a/uv.lock +++ b/uv.lock @@ -898,6 +898,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyyaml" }, + { name = "requests" }, { name = "scipy" }, { name = "sexpdata" }, { name = "sqlmodel" }, @@ -913,6 +914,7 @@ dev = [ { name = "pytest-mock" }, { name = "responses" }, { name = "ruff" }, + { name = "types-requests" }, ] [package.metadata] @@ -928,6 +930,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "requests", specifier = ">=2.32" }, { name = "scipy", specifier = ">=1.14" }, { name = "sexpdata", specifier = ">=1.0.2" }, { name = "sqlmodel", specifier = ">=0.0.22" }, @@ -943,6 +946,7 @@ dev = [ { name = "pytest-mock", specifier = ">=3.14" }, { name = "responses", specifier = ">=0.25" }, { name = "ruff", specifier = ">=0.7" }, + { name = "types-requests", specifier = ">=2.32" }, ] [[package]] @@ -2048,6 +2052,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "types-requests" +version = "2.33.0.20260508" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/6b/eb226bdd61a982c9a03e02c657fb4ab001733506e6423906ac142331f2e3/types_requests-2.33.0.20260508.tar.gz", hash = "sha256:81b2ae5f0d20967714a6aa5ef9284c05570d7cb06b7de8f2a77b918b63ddd411", size = 23991, upload-time = "2026-05-08T04:50:56.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/96/080db0afdf2c5cc5fe512b41354e8d114fe8f65e9510c56ff8dfd40216ce/types_requests-2.33.0.20260508-py3-none-any.whl", hash = "sha256:fa01459cca184229713df03709db46a905325906d27e042cd4fd7ea3d15d3400", size = 20722, upload-time = "2026-05-08T04:50:55.548Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"