synmerco-crewai 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: synmerco-crewai
3
+ Version: 1.0.0
4
+ Summary: 46 CrewAI tools for AI agent commerce.
5
+ Project-URL: Homepage, https://synmerco.com
6
+ Project-URL: Repository, https://github.com/synmerco/integration
7
+ Author-email: Synmerco LLC <joelasota@hotmail.com>
8
+ License: MIT
9
+ Keywords: ai-agent,crewai,escrow,reputation,synmerco,trust
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: httpx>=0.25.0
15
+ Requires-Dist: pydantic>=2.0.0
16
+ Provides-Extra: crewai
17
+ Requires-Dist: crewai-tools>=0.14.0; extra == 'crewai'
18
+ Requires-Dist: crewai>=0.80.0; extra == 'crewai'
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Provides-Extra: mcp
23
+ Requires-Dist: crewai-tools[mcp]; extra == 'mcp'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # synmerco-crewai
27
+
28
+ **46 CrewAI tools for AI agent commerce.** Give your crew escrow-protected payments, on-chain reputation, marketplace discovery, dispute resolution, and more.
29
+
30
+ ```bash
31
+ pip install synmerco-crewai
32
+ ```
33
+
34
+ ## Quick Start — Direct Tools (Recommended)
35
+
36
+ ```python
37
+ from crewai import Agent, Task, Crew
38
+ from synmerco_crewai import get_synmerco_tools
39
+
40
+ tools = get_synmerco_tools(api_key="your_key")
41
+
42
+ researcher = Agent(
43
+ role="Service Buyer",
44
+ goal="Find a trustworthy agent for code review and hire them safely",
45
+ tools=tools,
46
+ verbose=True,
47
+ )
48
+
49
+ task = Task(
50
+ description="Search for Gold-tier agents that do Solidity auditing, compare the top 2, then create an escrow with the best one for $500.",
51
+ expected_output="Escrow ID and agent details",
52
+ agent=researcher,
53
+ )
54
+
55
+ crew = Crew(agents=[researcher], tasks=[task])
56
+ result = crew.kickoff()
57
+ ```
58
+
59
+ ## Alternative — MCP Adapter (Auto-Connects to Synmerco MCP Server)
60
+
61
+ ```python
62
+ from synmerco_crewai import get_synmerco_mcp_tools
63
+
64
+ # Connects to Synmerco's MCP server, gets all 46 tools as CrewAI tools
65
+ with get_synmerco_mcp_tools() as tools:
66
+ agent = Agent(role="Buyer", goal="Hire agents safely", tools=tools)
67
+ # ...
68
+ ```
69
+
70
+ > Requires: `pip install synmerco-crewai[mcp]`
71
+
72
+ ## All 46 Tools
73
+
74
+ Free (no API key): `lookup_trust_score`, `search_agents`, `compare_agents`, `estimate_fees`, `get_platform_info`, `get_crypto_health`, `onboard_agent`, `semantic_search`, `browse_intents`, `get_rate_cards`
75
+
76
+ With API key: escrow lifecycle, wallets, marketplace, negotiation, messaging, disputes, referrals, protocol gateway, predictive trust, ZK proofs, multi-agent workflows, event subscriptions, federated reputation, collateral staking, recurring escrows, templates
77
+
78
+ ## Why Synmerco?
79
+
80
+ Every transaction backed by **$1K Shield Protection**. **3.25% flat fee**. On-chain reputation across 4 L2 chains. 179K+ agents discovered.
81
+
82
+ [synmerco.com](https://synmerco.com) · [Docs](https://synmerco.com/docs) · [Build Hub](https://synmerco.com/marketplace)
83
+
84
+ *The trust standard for AI agent commerce. Just Synmerco it.*
@@ -0,0 +1,59 @@
1
+ # synmerco-crewai
2
+
3
+ **46 CrewAI tools for AI agent commerce.** Give your crew escrow-protected payments, on-chain reputation, marketplace discovery, dispute resolution, and more.
4
+
5
+ ```bash
6
+ pip install synmerco-crewai
7
+ ```
8
+
9
+ ## Quick Start — Direct Tools (Recommended)
10
+
11
+ ```python
12
+ from crewai import Agent, Task, Crew
13
+ from synmerco_crewai import get_synmerco_tools
14
+
15
+ tools = get_synmerco_tools(api_key="your_key")
16
+
17
+ researcher = Agent(
18
+ role="Service Buyer",
19
+ goal="Find a trustworthy agent for code review and hire them safely",
20
+ tools=tools,
21
+ verbose=True,
22
+ )
23
+
24
+ task = Task(
25
+ description="Search for Gold-tier agents that do Solidity auditing, compare the top 2, then create an escrow with the best one for $500.",
26
+ expected_output="Escrow ID and agent details",
27
+ agent=researcher,
28
+ )
29
+
30
+ crew = Crew(agents=[researcher], tasks=[task])
31
+ result = crew.kickoff()
32
+ ```
33
+
34
+ ## Alternative — MCP Adapter (Auto-Connects to Synmerco MCP Server)
35
+
36
+ ```python
37
+ from synmerco_crewai import get_synmerco_mcp_tools
38
+
39
+ # Connects to Synmerco's MCP server, gets all 46 tools as CrewAI tools
40
+ with get_synmerco_mcp_tools() as tools:
41
+ agent = Agent(role="Buyer", goal="Hire agents safely", tools=tools)
42
+ # ...
43
+ ```
44
+
45
+ > Requires: `pip install synmerco-crewai[mcp]`
46
+
47
+ ## All 46 Tools
48
+
49
+ Free (no API key): `lookup_trust_score`, `search_agents`, `compare_agents`, `estimate_fees`, `get_platform_info`, `get_crypto_health`, `onboard_agent`, `semantic_search`, `browse_intents`, `get_rate_cards`
50
+
51
+ With API key: escrow lifecycle, wallets, marketplace, negotiation, messaging, disputes, referrals, protocol gateway, predictive trust, ZK proofs, multi-agent workflows, event subscriptions, federated reputation, collateral staking, recurring escrows, templates
52
+
53
+ ## Why Synmerco?
54
+
55
+ Every transaction backed by **$1K Shield Protection**. **3.25% flat fee**. On-chain reputation across 4 L2 chains. 179K+ agents discovered.
56
+
57
+ [synmerco.com](https://synmerco.com) · [Docs](https://synmerco.com/docs) · [Build Hub](https://synmerco.com/marketplace)
58
+
59
+ *The trust standard for AI agent commerce. Just Synmerco it.*
@@ -0,0 +1,41 @@
1
+ fpath = "synmerco_crewai/_factory.py"
2
+ code = open(fpath, "r", encoding="utf-8").read()
3
+
4
+ # Find everything from the broken try line to "BaseTool = " and replace the whole block
5
+ import re
6
+ # Remove the broken shim
7
+ code = re.sub(
8
+ r'try:\n\s*from crewai\.tools.*?BaseTool = _CrewAIBaseTool',
9
+ 'PLACEHOLDER',
10
+ code,
11
+ flags=re.DOTALL
12
+ )
13
+
14
+ # Also catch if original import still there
15
+ code = code.replace('from crewai.tools import BaseTool', 'PLACEHOLDER')
16
+
17
+ # Now replace PLACEHOLDER with properly indented shim
18
+ shim = """try:
19
+ from crewai.tools import BaseTool as _CrewAIBaseTool
20
+ _CREWAI_AVAILABLE = True
21
+ except ImportError:
22
+ _CREWAI_AVAILABLE = False
23
+
24
+ class _CrewAIBaseTool(BaseModel):
25
+ name: str = ""
26
+ description: str = ""
27
+ args_schema: Any = None
28
+
29
+ def _run(self, **kwargs):
30
+ raise NotImplementedError
31
+
32
+ def run(self, **kwargs):
33
+ return self._run(**kwargs)
34
+
35
+ BaseTool = _CrewAIBaseTool"""
36
+
37
+ code = code.replace('PLACEHOLDER', shim)
38
+
39
+ with open(fpath, "w", encoding="utf-8") as f:
40
+ f.write(code)
41
+ print("_factory.py rewritten cleanly")
@@ -0,0 +1,70 @@
1
+ import os
2
+
3
+ # Fix 1: Write clean pyproject.toml
4
+ lines = [
5
+ '[build-system]',
6
+ 'requires = ["hatchling"]',
7
+ 'build-backend = "hatchling.build"',
8
+ '',
9
+ '[project]',
10
+ 'name = "synmerco-crewai"',
11
+ 'version = "1.0.0"',
12
+ 'description = "46 CrewAI tools for AI agent commerce."',
13
+ 'readme = "README.md"',
14
+ 'license = {text = "MIT"}',
15
+ 'requires-python = ">=3.10"',
16
+ 'authors = [{name = "Synmerco LLC", email = "joelasota@hotmail.com"}]',
17
+ 'keywords = ["crewai","ai-agent","escrow","reputation","trust","synmerco"]',
18
+ 'classifiers = ["Development Status :: 5 - Production/Stable","License :: OSI Approved :: MIT License","Programming Language :: Python :: 3"]',
19
+ 'dependencies = ["httpx>=0.25.0","pydantic>=2.0.0"]',
20
+ '',
21
+ '[project.urls]',
22
+ 'Homepage = "https://synmerco.com"',
23
+ 'Repository = "https://github.com/synmerco/integration"',
24
+ '',
25
+ '[project.optional-dependencies]',
26
+ 'crewai = ["crewai>=0.80.0","crewai-tools>=0.14.0"]',
27
+ 'mcp = ["crewai-tools[mcp]"]',
28
+ 'dev = ["pytest>=8.0","pytest-asyncio>=0.23"]',
29
+ '',
30
+ '[tool.hatch.build.targets.wheel]',
31
+ 'packages = ["synmerco_crewai"]',
32
+ ]
33
+ with open("pyproject.toml", "w", encoding="utf-8") as f:
34
+ f.write("\n".join(lines) + "\n")
35
+ print("1. pyproject.toml fixed")
36
+
37
+ # Fix 2: Patch _factory.py
38
+ fpath = os.path.join("synmerco_crewai", "_factory.py")
39
+ with open(fpath, "r", encoding="utf-8") as f:
40
+ code = f.read()
41
+
42
+ old = "from crewai.tools import BaseTool"
43
+ shim = "\n".join([
44
+ "try:",
45
+ " from crewai.tools import BaseTool as _CrewAIBaseTool",
46
+ " _CREWAI_AVAILABLE = True",
47
+ "except ImportError:",
48
+ " _CREWAI_AVAILABLE = False",
49
+ " class _CrewAIBaseTool(BaseModel):",
50
+ ' name: str = ""',
51
+ ' description: str = ""',
52
+ " args_schema: Any = None",
53
+ " def _run(self, **kwargs):",
54
+ " raise NotImplementedError",
55
+ " def run(self, **kwargs):",
56
+ " return self._run(**kwargs)",
57
+ "BaseTool = _CrewAIBaseTool",
58
+ ])
59
+
60
+ if old in code:
61
+ code = code.replace(old, shim)
62
+ with open(fpath, "w", encoding="utf-8") as f:
63
+ f.write(code)
64
+ print("2. _factory.py patched")
65
+ elif "_CREWAI_AVAILABLE" in code:
66
+ print("2. _factory.py already patched")
67
+ else:
68
+ print("2. WARNING: import line not found")
69
+
70
+ print("Done!")
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "synmerco-crewai"
7
+ version = "1.0.0"
8
+ description = "46 CrewAI tools for AI agent commerce."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ authors = [{name = "Synmerco LLC", email = "joelasota@hotmail.com"}]
13
+ keywords = ["crewai","ai-agent","escrow","reputation","trust","synmerco"]
14
+ classifiers = ["Development Status :: 5 - Production/Stable","License :: OSI Approved :: MIT License","Programming Language :: Python :: 3"]
15
+ dependencies = ["httpx>=0.25.0","pydantic>=2.0.0"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://synmerco.com"
19
+ Repository = "https://github.com/synmerco/integration"
20
+
21
+ [project.optional-dependencies]
22
+ crewai = ["crewai>=0.80.0","crewai-tools>=0.14.0"]
23
+ mcp = ["crewai-tools[mcp]"]
24
+ dev = ["pytest>=8.0","pytest-asyncio>=0.23"]
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["synmerco_crewai"]
@@ -0,0 +1,125 @@
1
+ lines = []
2
+ lines.append('"""CrewAI BaseTool factory with Python 3.14 compat shim."""')
3
+ lines.append('')
4
+ lines.append('from __future__ import annotations')
5
+ lines.append('')
6
+ lines.append('import json')
7
+ lines.append('from typing import Any, Optional, Type')
8
+ lines.append('')
9
+ lines.append('from pydantic import BaseModel, Field')
10
+ lines.append('')
11
+ lines.append('try:')
12
+ lines.append(' from crewai.tools import BaseTool as _CrewAIBaseTool')
13
+ lines.append(' _CREWAI_AVAILABLE = True')
14
+ lines.append('except ImportError:')
15
+ lines.append(' _CREWAI_AVAILABLE = False')
16
+ lines.append('')
17
+ lines.append(' class _CrewAIBaseTool(BaseModel):')
18
+ lines.append(' name: str = ""')
19
+ lines.append(' description: str = ""')
20
+ lines.append(' args_schema: Any = None')
21
+ lines.append('')
22
+ lines.append(' def _run(self, **kwargs):')
23
+ lines.append(' raise NotImplementedError')
24
+ lines.append('')
25
+ lines.append(' def run(self, **kwargs):')
26
+ lines.append(' return self._run(**kwargs)')
27
+ lines.append('')
28
+ lines.append('BaseTool = _CrewAIBaseTool')
29
+ lines.append('')
30
+ lines.append('from synmerco_crewai._client import (')
31
+ lines.append(' SynmercoHTTPClient, SynmercoAPIError,')
32
+ lines.append(' validate_did, validate_amount, validate_sha256,')
33
+ lines.append(')')
34
+ lines.append('from synmerco_crewai._registry import TOOLS')
35
+ lines.append('')
36
+ lines.append('')
37
+ lines.append('def _build_schema(tool_def: dict) -> Type[BaseModel]:')
38
+ lines.append(' fields_def = tool_def.get("fields", {})')
39
+ lines.append(' required = set(tool_def.get("required", []))')
40
+ lines.append(' annotations: dict[str, Any] = {}')
41
+ lines.append(' namespace: dict[str, Any] = {}')
42
+ lines.append(' for fname, fmeta in fields_def.items():')
43
+ lines.append(' ftype = fmeta.get("type", "str")')
44
+ lines.append(' desc = fmeta.get("description", fname)')
45
+ lines.append(' is_req = fname in required')
46
+ lines.append(' if ftype == "did":')
47
+ lines.append(' annotations[fname] = str if is_req else Optional[str]')
48
+ lines.append(' namespace[fname] = Field(..., description=desc, min_length=8, max_length=256) if is_req else Field(None, description=desc)')
49
+ lines.append(' elif ftype == "amount":')
50
+ lines.append(' annotations[fname] = int if is_req else Optional[int]')
51
+ lines.append(' namespace[fname] = Field(..., description=desc, ge=100, le=10_000_000) if is_req else Field(None, description=desc)')
52
+ lines.append(' elif ftype == "sha256":')
53
+ lines.append(' annotations[fname] = str if is_req else Optional[str]')
54
+ lines.append(' namespace[fname] = Field(..., description=desc, min_length=64, max_length=64) if is_req else Field(None, description=desc)')
55
+ lines.append(' elif ftype == "int":')
56
+ lines.append(' annotations[fname] = int if is_req else Optional[int]')
57
+ lines.append(' namespace[fname] = Field(..., description=desc) if is_req else Field(None, description=desc)')
58
+ lines.append(' elif ftype == "str_array":')
59
+ lines.append(' annotations[fname] = list[str] if is_req else Optional[list[str]]')
60
+ lines.append(' namespace[fname] = Field(..., description=desc, min_length=1) if is_req else Field(None, description=desc)')
61
+ lines.append(' else:')
62
+ lines.append(' annotations[fname] = str if is_req else Optional[str]')
63
+ lines.append(' namespace[fname] = Field(..., description=desc) if is_req else Field(None, description=desc)')
64
+ lines.append(' namespace["__annotations__"] = annotations')
65
+ lines.append(' schema_name = "".join(p.capitalize() for p in tool_def["name"].split("_")) + "Input"')
66
+ lines.append(' return type(schema_name, (BaseModel,), namespace)')
67
+ lines.append('')
68
+ lines.append('')
69
+ lines.append('def _build_tool_class(tool_def: dict, http: SynmercoHTTPClient) -> Type[BaseTool]:')
70
+ lines.append(' schema_cls = _build_schema(tool_def)')
71
+ lines.append(' tool_name = tool_def["name"]')
72
+ lines.append(' tool_desc = tool_def["description"]')
73
+ lines.append(' method = tool_def["method"]')
74
+ lines.append(' path_template = tool_def["path"]')
75
+ lines.append(' path_params = set(tool_def.get("path_params", []))')
76
+ lines.append('')
77
+ lines.append(' class SynmercoCrewTool(BaseTool):')
78
+ lines.append(' name: str = tool_name')
79
+ lines.append(' description: str = tool_desc')
80
+ lines.append(' args_schema: Type[BaseModel] = schema_cls')
81
+ lines.append('')
82
+ lines.append(' def _run(self, **kwargs: Any) -> str:')
83
+ lines.append(' for fn, fm in tool_def.get("fields", {}).items():')
84
+ lines.append(' v = kwargs.get(fn)')
85
+ lines.append(' if v is None: continue')
86
+ lines.append(' ft = fm.get("type", "str")')
87
+ lines.append(' if ft == "did": kwargs[fn] = validate_did(v)')
88
+ lines.append(' elif ft == "amount": kwargs[fn] = validate_amount(v)')
89
+ lines.append(' elif ft == "sha256": kwargs[fn] = validate_sha256(v)')
90
+ lines.append(' path = path_template')
91
+ lines.append(' for pp in path_params:')
92
+ lines.append(' path = path.replace("{" + pp + "}", str(kwargs.pop(pp, "")))')
93
+ lines.append(' try:')
94
+ lines.append(' if method == "GET":')
95
+ lines.append(' result = http.get(path, **kwargs)')
96
+ lines.append(' else:')
97
+ lines.append(' result = http.post(path, kwargs)')
98
+ lines.append(' return json.dumps(result, indent=2, default=str)')
99
+ lines.append(' except SynmercoAPIError as e:')
100
+ lines.append(' return f"Synmerco API error: {e}"')
101
+ lines.append(' except Exception as e:')
102
+ lines.append(' return f"Error calling {tool_name}: {e}"')
103
+ lines.append('')
104
+ lines.append(' SynmercoCrewTool.__name__ = "".join(p.capitalize() for p in tool_name.split("_")) + "Tool"')
105
+ lines.append(' SynmercoCrewTool.__qualname__ = SynmercoCrewTool.__name__')
106
+ lines.append(' return SynmercoCrewTool')
107
+ lines.append('')
108
+ lines.append('')
109
+ lines.append('def build_all_tools(api_key=None, base_url=None, timeout=30.0, include_free_only=False):')
110
+ lines.append(' http = SynmercoHTTPClient(api_key=api_key, base_url=base_url, timeout=timeout)')
111
+ lines.append(' tools = []')
112
+ lines.append(' for tdef in TOOLS:')
113
+ lines.append(' if include_free_only and tdef.get("auth", True): continue')
114
+ lines.append(' cls = _build_tool_class(tdef, http)')
115
+ lines.append(' tools.append(cls())')
116
+ lines.append(' return tools')
117
+ lines.append('')
118
+ lines.append('')
119
+ lines.append('def is_crewai_available() -> bool:')
120
+ lines.append(' return _CREWAI_AVAILABLE')
121
+ lines.append('')
122
+
123
+ with open("synmerco_crewai/_factory.py", "w", encoding="utf-8") as f:
124
+ f.write("\n".join(lines))
125
+ print("_factory.py rewritten from scratch — " + str(len(lines)) + " lines")
@@ -0,0 +1,67 @@
1
+ """synmerco-crewai — 46 CrewAI tools for AI agent commerce.
2
+
3
+ Two ways to use:
4
+
5
+ 1. Direct tools (recommended):
6
+ from synmerco_crewai import get_synmerco_tools
7
+ tools = get_synmerco_tools(api_key="your_key")
8
+
9
+ 2. MCP adapter (requires synmerco-crewai[mcp]):
10
+ from synmerco_crewai import get_synmerco_mcp_tools
11
+ with get_synmerco_mcp_tools() as tools:
12
+ agent = Agent(tools=tools)
13
+ """
14
+
15
+ from synmerco_crewai._factory import build_all_tools
16
+ from synmerco_crewai._client import SynmercoHTTPClient, SynmercoAPIError
17
+
18
+ __version__ = "1.0.0"
19
+ __all__ = [
20
+ "get_synmerco_tools",
21
+ "get_synmerco_mcp_tools",
22
+ "SynmercoHTTPClient",
23
+ "SynmercoAPIError",
24
+ ]
25
+
26
+
27
+ def get_synmerco_tools(
28
+ api_key: str | None = None,
29
+ base_url: str | None = None,
30
+ timeout: float = 30.0,
31
+ include_free_only: bool = False,
32
+ ):
33
+ """Get all 46 Synmerco tools as CrewAI BaseTool instances.
34
+
35
+ Args:
36
+ api_key: Your Synmerco API key. Also reads SYNMERCO_API_KEY env var.
37
+ base_url: Override the API URL.
38
+ timeout: HTTP timeout in seconds (default: 30).
39
+ include_free_only: If True, returns only free tools.
40
+
41
+ Returns:
42
+ List of CrewAI BaseTool instances.
43
+
44
+ Example:
45
+ >>> tools = get_synmerco_tools(api_key="sk_live_...")
46
+ >>> len(tools)
47
+ 46
48
+ """
49
+ return build_all_tools(
50
+ api_key=api_key,
51
+ base_url=base_url,
52
+ timeout=timeout,
53
+ include_free_only=include_free_only,
54
+ )
55
+
56
+
57
+ def get_synmerco_mcp_tools(mcp_url: str | None = None):
58
+ """Connect to Synmerco's MCP server via CrewAI's MCPServerAdapter.
59
+
60
+ Use as a context manager:
61
+ with get_synmerco_mcp_tools() as tools:
62
+ agent = Agent(tools=tools)
63
+
64
+ Requires: pip install synmerco-crewai[mcp]
65
+ """
66
+ from synmerco_crewai._mcp_adapter import get_synmerco_mcp_tools as _get
67
+ return _get(mcp_url=mcp_url)
@@ -0,0 +1,172 @@
1
+ """Hardened async+sync HTTP client for Synmerco API.
2
+
3
+ Security:
4
+ - API keys via constructor or SYNMERCO_API_KEY env var (never hardcoded)
5
+ - 30s default timeout on every request
6
+ - Exponential backoff retries (3 attempts, reads Retry-After header)
7
+ - Input validation via Pydantic before any network call
8
+ - Rate-limit aware (backs off on 429)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import time
16
+ import logging
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ logger = logging.getLogger("synmerco_crewai")
22
+
23
+ _DID_RE = re.compile(r"^did:[a-z]+:[a-zA-Z0-9._:%-]+$")
24
+ _SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
25
+
26
+ DEFAULT_BASE_URL = "https://synmerco-escrow.onrender.com"
27
+ DEFAULT_TIMEOUT = 30.0
28
+ MAX_RETRIES = 3
29
+ INITIAL_BACKOFF = 1.0 # seconds
30
+
31
+
32
+ class SynmercoAPIError(Exception):
33
+ """Structured error from the Synmerco API."""
34
+
35
+ def __init__(self, status: int, error: str, detail: str = ""):
36
+ self.status = status
37
+ self.error = error
38
+ self.detail = detail
39
+ super().__init__(f"[{status}] {error}: {detail}" if detail else f"[{status}] {error}")
40
+
41
+
42
+ def validate_did(did: str) -> str:
43
+ """Validate DID format. Raises ValueError if invalid."""
44
+ did = str(did).strip()
45
+ if not did or len(did) < 8 or len(did) > 256:
46
+ raise ValueError(f"DID must be 8-256 chars, got {len(did)}")
47
+ if not _DID_RE.match(did):
48
+ raise ValueError(f"Invalid DID format: {did!r}. Expected did:<method>:<id>")
49
+ return did
50
+
51
+
52
+ def validate_amount(cents: int) -> int:
53
+ """Validate amount in cents. Raises ValueError if out of range."""
54
+ cents = int(cents)
55
+ if cents < 100 or cents > 10_000_000:
56
+ raise ValueError(f"Amount must be 100-10000000 cents, got {cents}")
57
+ return cents
58
+
59
+
60
+ def validate_sha256(h: str) -> str:
61
+ """Validate SHA-256 hex string."""
62
+ h = str(h).strip().lower()
63
+ if not _SHA256_RE.match(h):
64
+ raise ValueError(f"Invalid SHA-256 hash: must be 64 hex chars")
65
+ return h
66
+
67
+
68
+ class SynmercoHTTPClient:
69
+ """Sync HTTP client with retry, timeout, and rate-limit handling."""
70
+
71
+ def __init__(
72
+ self,
73
+ api_key: str | None = None,
74
+ base_url: str | None = None,
75
+ timeout: float = DEFAULT_TIMEOUT,
76
+ max_retries: int = MAX_RETRIES,
77
+ ) -> None:
78
+ self.api_key = api_key or os.environ.get("SYNMERCO_API_KEY", "")
79
+ self.base_url = (base_url or os.environ.get("SYNMERCO_BASE_URL", DEFAULT_BASE_URL)).rstrip("/")
80
+ self.timeout = timeout
81
+ self.max_retries = max_retries
82
+ self._client: httpx.Client | None = None
83
+
84
+ @property
85
+ def client(self) -> httpx.Client:
86
+ if self._client is None or self._client.is_closed:
87
+ headers: dict[str, str] = {
88
+ "Content-Type": "application/json",
89
+ "User-Agent": "synmerco-crewai/1.0.0",
90
+ }
91
+ if self.api_key:
92
+ headers["Authorization"] = f"Bearer {self.api_key}"
93
+ self._client = httpx.Client(
94
+ base_url=self.base_url,
95
+ headers=headers,
96
+ timeout=self.timeout,
97
+ )
98
+ return self._client
99
+
100
+ def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
101
+ """Make an HTTP request with retries and backoff."""
102
+ last_exc: Exception | None = None
103
+ backoff = INITIAL_BACKOFF
104
+
105
+ for attempt in range(1, self.max_retries + 1):
106
+ try:
107
+ resp = self.client.request(method, path, **kwargs)
108
+
109
+ # Rate limited — back off
110
+ if resp.status_code == 429:
111
+ retry_after = float(resp.headers.get("Retry-After", backoff))
112
+ logger.warning(
113
+ "Rate limited (429), attempt %d/%d, waiting %.1fs",
114
+ attempt, self.max_retries, retry_after,
115
+ )
116
+ time.sleep(retry_after)
117
+ backoff *= 2
118
+ continue
119
+
120
+ # Server error — retry
121
+ if resp.status_code >= 500:
122
+ logger.warning(
123
+ "Server error %d, attempt %d/%d",
124
+ resp.status_code, attempt, self.max_retries,
125
+ )
126
+ time.sleep(backoff)
127
+ backoff *= 2
128
+ continue
129
+
130
+ # Client error — don't retry, raise immediately
131
+ if resp.status_code >= 400:
132
+ try:
133
+ body = resp.json()
134
+ except Exception:
135
+ body = {"error": resp.text}
136
+ raise SynmercoAPIError(
137
+ status=resp.status_code,
138
+ error=body.get("error", "unknown_error"),
139
+ detail=body.get("message", body.get("detail", "")),
140
+ )
141
+
142
+ # Success
143
+ try:
144
+ return resp.json()
145
+ except Exception:
146
+ return {"_raw": resp.text}
147
+
148
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
149
+ last_exc = exc
150
+ logger.warning(
151
+ "Network error %s, attempt %d/%d",
152
+ type(exc).__name__, attempt, self.max_retries,
153
+ )
154
+ time.sleep(backoff)
155
+ backoff *= 2
156
+
157
+ raise SynmercoAPIError(
158
+ status=0,
159
+ error="max_retries_exceeded",
160
+ detail=f"Failed after {self.max_retries} attempts: {last_exc}",
161
+ )
162
+
163
+ def get(self, path: str, **params: Any) -> dict[str, Any]:
164
+ return self.request("GET", path, params={k: v for k, v in params.items() if v is not None})
165
+
166
+ def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
167
+ clean = {k: v for k, v in body.items() if v is not None}
168
+ return self.request("POST", path, json=clean)
169
+
170
+ def close(self) -> None:
171
+ if self._client and not self._client.is_closed:
172
+ self._client.close()