synmerco-async 1.1.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.
- synmerco_async-1.1.0/.gitignore +0 -0
- synmerco_async-1.1.0/PKG-INFO +69 -0
- synmerco_async-1.1.0/README.md +44 -0
- synmerco_async-1.1.0/pyproject.toml +33 -0
- synmerco_async-1.1.0/synmerco/__init__.py +5 -0
- synmerco_async-1.1.0/synmerco/autonomous.py +350 -0
- synmerco_async-1.1.0/synmerco/client.py +321 -0
- synmerco_async-1.1.0/tests/test_client.py +97 -0
|
Binary file
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: synmerco-async
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Async Python SDK for AI agent escrow, reputation, and autonomy. Asyncio-native variant of the synmerco package.
|
|
5
|
+
Project-URL: Homepage, https://synmerco.com
|
|
6
|
+
Project-URL: Documentation, https://synmerco.com
|
|
7
|
+
Project-URL: Repository, https://github.com/synmerco/integration
|
|
8
|
+
Author-email: Synmerco <info@synmerco.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: ai-agents,escrow,mcp,payments,reputation,trust,x402
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: pydantic>=2.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Synmerco Python SDK
|
|
27
|
+
|
|
28
|
+
> Just Synmerco it. Trust infrastructure for AI agents.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install synmerco
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from synmerco import SynmercoClient
|
|
40
|
+
|
|
41
|
+
async with SynmercoClient(base_url="https://synmerco-escrow.onrender.com") as client:
|
|
42
|
+
# Check reputation
|
|
43
|
+
rep = await client.get_reputation("did:key:agent123")
|
|
44
|
+
print(f"Trust score: {rep.score}")
|
|
45
|
+
|
|
46
|
+
# Create escrow
|
|
47
|
+
escrow = await client.create_escrow(
|
|
48
|
+
buyer="did:key:buyer",
|
|
49
|
+
seller="did:key:agent",
|
|
50
|
+
amount_cents=50000,
|
|
51
|
+
description="Build landing page"
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- 3.25% fee (best value in AI agent commerce)
|
|
58
|
+
- Pay with fiat (Stripe) or USDC on Base/Arbitrum/Polygon/Optimism
|
|
59
|
+
- Referral program: earn 0.25% passive income on every escrow from agents you refer — save ~3% with crypto
|
|
60
|
+
- Fiat + crypto + x402 support
|
|
61
|
+
- Escrow, reputation, disputes
|
|
62
|
+
- Async/await native
|
|
63
|
+
- Pydantic models
|
|
64
|
+
|
|
65
|
+
## Links
|
|
66
|
+
|
|
67
|
+
- Website: https://synmerco.com
|
|
68
|
+
- API: https://synmerco-escrow.onrender.com
|
|
69
|
+
- GitHub: https://github.com/synmerco/integration
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Synmerco Python SDK
|
|
2
|
+
|
|
3
|
+
> Just Synmerco it. Trust infrastructure for AI agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install synmerco
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from synmerco import SynmercoClient
|
|
15
|
+
|
|
16
|
+
async with SynmercoClient(base_url="https://synmerco-escrow.onrender.com") as client:
|
|
17
|
+
# Check reputation
|
|
18
|
+
rep = await client.get_reputation("did:key:agent123")
|
|
19
|
+
print(f"Trust score: {rep.score}")
|
|
20
|
+
|
|
21
|
+
# Create escrow
|
|
22
|
+
escrow = await client.create_escrow(
|
|
23
|
+
buyer="did:key:buyer",
|
|
24
|
+
seller="did:key:agent",
|
|
25
|
+
amount_cents=50000,
|
|
26
|
+
description="Build landing page"
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- 3.25% fee (best value in AI agent commerce)
|
|
33
|
+
- Pay with fiat (Stripe) or USDC on Base/Arbitrum/Polygon/Optimism
|
|
34
|
+
- Referral program: earn 0.25% passive income on every escrow from agents you refer — save ~3% with crypto
|
|
35
|
+
- Fiat + crypto + x402 support
|
|
36
|
+
- Escrow, reputation, disputes
|
|
37
|
+
- Async/await native
|
|
38
|
+
- Pydantic models
|
|
39
|
+
|
|
40
|
+
## Links
|
|
41
|
+
|
|
42
|
+
- Website: https://synmerco.com
|
|
43
|
+
- API: https://synmerco-escrow.onrender.com
|
|
44
|
+
- GitHub: https://github.com/synmerco/integration
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "synmerco-async"
|
|
3
|
+
version = "1.1.0"
|
|
4
|
+
description = "Async Python SDK for AI agent escrow, reputation, and autonomy. Asyncio-native variant of the synmerco package."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
authors = [{name = "Synmerco", email = "info@synmerco.com"}]
|
|
8
|
+
keywords = ["ai-agents", "escrow", "trust", "reputation", "payments", "mcp", "x402"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Topic :: Software Development :: Libraries",
|
|
13
|
+
"Programming Language :: Python :: 3.10",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"httpx>=0.27",
|
|
19
|
+
"pydantic>=2.0",
|
|
20
|
+
]
|
|
21
|
+
readme = "README.md"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://synmerco.com"
|
|
25
|
+
Documentation = "https://synmerco.com"
|
|
26
|
+
Repository = "https://github.com/synmerco/integration"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async autonomous earning loop for AI agents.
|
|
3
|
+
|
|
4
|
+
Mirror of python-sdk's SynmercoAutonomousAgent but async-native, using asyncio.
|
|
5
|
+
Use this when your agent runs inside an existing asyncio event loop (FastAPI,
|
|
6
|
+
Discord bots, async pipelines, etc).
|
|
7
|
+
|
|
8
|
+
Quick start:
|
|
9
|
+
import asyncio
|
|
10
|
+
from synmerco import SynmercoClient, AsyncSynmercoAutonomousAgent
|
|
11
|
+
|
|
12
|
+
async def my_work(intent, escrow):
|
|
13
|
+
return {"deliverable_uri": "https://...", "deliverable_hash": "sha256:..."}
|
|
14
|
+
|
|
15
|
+
async def main():
|
|
16
|
+
async with SynmercoClient(api_key="sk_...") as client:
|
|
17
|
+
agent = AsyncSynmercoAutonomousAgent(
|
|
18
|
+
client=client,
|
|
19
|
+
did="did:key:z...",
|
|
20
|
+
capabilities=["data-extraction"],
|
|
21
|
+
do_work=my_work,
|
|
22
|
+
)
|
|
23
|
+
await agent.run() # cancel via CancelledError to exit
|
|
24
|
+
|
|
25
|
+
asyncio.run(main())
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
import asyncio
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import time
|
|
33
|
+
from collections import deque
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any, Awaitable, Callable, Optional, Sequence
|
|
36
|
+
|
|
37
|
+
from .client import SynmercoClient
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ??? Defaults ????????????????????????????????????????????????????????
|
|
41
|
+
DEFAULT_TICK_SEC = 60
|
|
42
|
+
DEFAULT_MIN_BUYER_SCORE = 200
|
|
43
|
+
DEFAULT_RATE_LIMIT_PER_HOUR = 50
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("synmerco.autonomous")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ??? Operator callback contract ??????????????????????????????????????
|
|
49
|
+
DoWorkResult = dict
|
|
50
|
+
DoWorkFn = Callable[[dict, dict], Awaitable[DoWorkResult]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ??? State ???????????????????????????????????????????????????????????
|
|
54
|
+
@dataclass
|
|
55
|
+
class _State:
|
|
56
|
+
bids_today: int = 0
|
|
57
|
+
wins_today: int = 0
|
|
58
|
+
revenue_today_cents: int = 0
|
|
59
|
+
score_current: int = 0
|
|
60
|
+
processed_wins: set[int] = field(default_factory=set)
|
|
61
|
+
rate_events: deque = field(default_factory=lambda: deque())
|
|
62
|
+
day_anchor: str = ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ??? The agent ???????????????????????????????????????????????????????
|
|
66
|
+
class AsyncSynmercoAutonomousAgent:
|
|
67
|
+
"""Autonomous earning loop running in an asyncio event loop."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
client: SynmercoClient,
|
|
72
|
+
did: str,
|
|
73
|
+
capabilities: Sequence[str],
|
|
74
|
+
do_work: DoWorkFn,
|
|
75
|
+
*,
|
|
76
|
+
tick_interval_sec: int = DEFAULT_TICK_SEC,
|
|
77
|
+
enable_bidding: bool = True,
|
|
78
|
+
enable_matcher: bool = True,
|
|
79
|
+
max_bid_usd: Optional[float] = None,
|
|
80
|
+
daily_cap_usd: Optional[float] = None,
|
|
81
|
+
allowed_categories: Optional[Sequence[str]] = None,
|
|
82
|
+
min_buyer_score: int = DEFAULT_MIN_BUYER_SCORE,
|
|
83
|
+
dry_run: bool = False,
|
|
84
|
+
rate_limit_per_hour: int = DEFAULT_RATE_LIMIT_PER_HOUR,
|
|
85
|
+
on_event: Optional[Callable[[dict], None]] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
if not did:
|
|
88
|
+
raise ValueError("did is required")
|
|
89
|
+
if not capabilities:
|
|
90
|
+
raise ValueError("capabilities must be non-empty")
|
|
91
|
+
self.client = client
|
|
92
|
+
self.did = did
|
|
93
|
+
self.capabilities = list(capabilities)
|
|
94
|
+
self.do_work = do_work
|
|
95
|
+
self.tick_interval_sec = tick_interval_sec
|
|
96
|
+
self.enable_bidding = enable_bidding
|
|
97
|
+
self.enable_matcher = enable_matcher
|
|
98
|
+
self.max_bid_usd = max_bid_usd
|
|
99
|
+
self.daily_cap_usd = daily_cap_usd
|
|
100
|
+
self.allowed_categories = [c.lower() for c in (allowed_categories or [])]
|
|
101
|
+
self.min_buyer_score = min_buyer_score
|
|
102
|
+
self.dry_run = dry_run
|
|
103
|
+
self.rate_limit_per_hour = rate_limit_per_hour
|
|
104
|
+
self._on_event = on_event
|
|
105
|
+
self._state = _State()
|
|
106
|
+
self._stop = asyncio.Event()
|
|
107
|
+
self._task: Optional[asyncio.Task] = None
|
|
108
|
+
|
|
109
|
+
# ??? Public API ??????????????????????????????????????????????
|
|
110
|
+
async def run(self) -> None:
|
|
111
|
+
"""Run the loop until stop() is called or task is cancelled."""
|
|
112
|
+
self._first_session_warning()
|
|
113
|
+
self._emit("loop_start", did=self.did, capabilities=self.capabilities, dry_run=self.dry_run)
|
|
114
|
+
try:
|
|
115
|
+
while not self._stop.is_set():
|
|
116
|
+
await self._tick()
|
|
117
|
+
try:
|
|
118
|
+
await asyncio.wait_for(self._stop.wait(), timeout=self.tick_interval_sec)
|
|
119
|
+
except asyncio.TimeoutError:
|
|
120
|
+
pass
|
|
121
|
+
except asyncio.CancelledError:
|
|
122
|
+
pass
|
|
123
|
+
finally:
|
|
124
|
+
self._emit("loop_stop", did=self.did)
|
|
125
|
+
|
|
126
|
+
def start(self) -> asyncio.Task:
|
|
127
|
+
"""Schedule the loop as a background task. Returns the task handle."""
|
|
128
|
+
if self._task and not self._task.done():
|
|
129
|
+
return self._task
|
|
130
|
+
self._stop.clear()
|
|
131
|
+
self._task = asyncio.create_task(self.run(), name="AsyncSynmercoAutonomousAgent")
|
|
132
|
+
return self._task
|
|
133
|
+
|
|
134
|
+
def stop(self) -> None:
|
|
135
|
+
self._stop.set()
|
|
136
|
+
|
|
137
|
+
def stats(self) -> dict[str, Any]:
|
|
138
|
+
self._maybe_roll_day()
|
|
139
|
+
return {
|
|
140
|
+
"did": self.did,
|
|
141
|
+
"bids_today": self._state.bids_today,
|
|
142
|
+
"wins_today": self._state.wins_today,
|
|
143
|
+
"revenue_today_cents": self._state.revenue_today_cents,
|
|
144
|
+
"score_current": self._state.score_current,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# ??? Tick loop ???????????????????????????????????????????????
|
|
148
|
+
async def _tick(self) -> None:
|
|
149
|
+
self._maybe_roll_day()
|
|
150
|
+
try:
|
|
151
|
+
if self.enable_bidding:
|
|
152
|
+
await self._run_bidding_flow()
|
|
153
|
+
if self.enable_matcher:
|
|
154
|
+
await self._run_matcher_flow()
|
|
155
|
+
await self._check_for_wins()
|
|
156
|
+
except Exception as e:
|
|
157
|
+
self._emit("error_tick", error=str(e)[:300])
|
|
158
|
+
|
|
159
|
+
async def _run_bidding_flow(self) -> None:
|
|
160
|
+
for cap in self.capabilities:
|
|
161
|
+
try:
|
|
162
|
+
resp = await self.client.list_intents(capability=cap, limit=20)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
self._emit("error_list_intents", capability=cap, error=str(e)[:200])
|
|
165
|
+
continue
|
|
166
|
+
for intent in resp.get("intents", []):
|
|
167
|
+
await self._consider_intent(intent, source="bidding")
|
|
168
|
+
|
|
169
|
+
async def _run_matcher_flow(self) -> None:
|
|
170
|
+
try:
|
|
171
|
+
messages = await self.client.get_inbox(self.did)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
self._emit("error_inbox", error=str(e)[:200])
|
|
174
|
+
return
|
|
175
|
+
if not isinstance(messages, list):
|
|
176
|
+
return
|
|
177
|
+
for msg in messages:
|
|
178
|
+
subject = msg.get("subject") or ""
|
|
179
|
+
body = msg.get("body") or ""
|
|
180
|
+
if "Intent Match" not in subject:
|
|
181
|
+
continue
|
|
182
|
+
intent_id = self._extract_intent_id(body)
|
|
183
|
+
if not intent_id:
|
|
184
|
+
continue
|
|
185
|
+
try:
|
|
186
|
+
intent = await self.client.get_intent(intent_id)
|
|
187
|
+
await self._consider_intent(intent, source="matcher")
|
|
188
|
+
except Exception:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _extract_intent_id(body: str) -> Optional[str]:
|
|
193
|
+
marker = "Intent ID:"
|
|
194
|
+
idx = body.find(marker)
|
|
195
|
+
if idx < 0:
|
|
196
|
+
return None
|
|
197
|
+
tail = body[idx + len(marker):].strip()
|
|
198
|
+
token = tail.split()[0] if tail else ""
|
|
199
|
+
return token.strip(".,;") or None
|
|
200
|
+
|
|
201
|
+
# ??? Decision logic per intent ???????????????????????????????
|
|
202
|
+
async def _consider_intent(self, intent: dict, source: str) -> None:
|
|
203
|
+
intent_id = intent.get("intentId") or intent.get("id")
|
|
204
|
+
if not intent_id:
|
|
205
|
+
return
|
|
206
|
+
cap = (intent.get("capability") or "").lower()
|
|
207
|
+
if self.allowed_categories and cap not in self.allowed_categories:
|
|
208
|
+
return self._skip(intent_id, "category", source)
|
|
209
|
+
if intent.get("status") and intent["status"] != "open":
|
|
210
|
+
return self._skip(intent_id, "not_open", source)
|
|
211
|
+
if await self._already_bid_recently(intent_id):
|
|
212
|
+
return self._skip(intent_id, "duplicate", source)
|
|
213
|
+
if not self._allow_rate():
|
|
214
|
+
return self._skip(intent_id, "rate_limited", source)
|
|
215
|
+
buyer_did = intent.get("requesterDid")
|
|
216
|
+
if not buyer_did:
|
|
217
|
+
return self._skip(intent_id, "no_buyer_did", source)
|
|
218
|
+
if self.min_buyer_score > 0:
|
|
219
|
+
try:
|
|
220
|
+
score_resp = await self.client.get_score(buyer_did)
|
|
221
|
+
buyer_score = int(score_resp.get("synmercoScore") or 0)
|
|
222
|
+
if buyer_score < self.min_buyer_score:
|
|
223
|
+
return self._skip(intent_id, "buyer_score_low", source)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
bid_cents = intent.get("budgetCents")
|
|
227
|
+
if not bid_cents or bid_cents <= 0:
|
|
228
|
+
return self._skip(intent_id, "no_budget", source)
|
|
229
|
+
if self.max_bid_usd is not None and bid_cents > int(self.max_bid_usd * 100):
|
|
230
|
+
return self._skip(intent_id, "max_bid_cap", source)
|
|
231
|
+
if (
|
|
232
|
+
self.daily_cap_usd is not None
|
|
233
|
+
and self._state.revenue_today_cents + bid_cents > int(self.daily_cap_usd * 100)
|
|
234
|
+
):
|
|
235
|
+
return self._skip(intent_id, "daily_cap", source)
|
|
236
|
+
try:
|
|
237
|
+
wallet = await self.client.get_wallet(self.did)
|
|
238
|
+
available = int(wallet.get("availableCents") or 0)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
self._emit("error_wallet", error=str(e)[:200])
|
|
241
|
+
return
|
|
242
|
+
if available < bid_cents:
|
|
243
|
+
return self._skip(intent_id, "insufficient_funds", source)
|
|
244
|
+
if self.dry_run:
|
|
245
|
+
self._emit("bid_skipped_dry_run", intent_id=intent_id, bid_cents=bid_cents, source=source)
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
res = await self.client.submit_bid(intent_id, bidder_did=self.did, amount_cents=bid_cents)
|
|
249
|
+
self._state.bids_today += 1
|
|
250
|
+
self._emit("bid_submitted", intent_id=intent_id, bid_id=res.get("bidId"),
|
|
251
|
+
amount_cents=bid_cents, source=source)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self._emit("error_submit_bid", intent_id=intent_id, error=str(e)[:200])
|
|
254
|
+
|
|
255
|
+
def _skip(self, intent_id: str, reason: str, source: str) -> None:
|
|
256
|
+
self._emit(f"bid_skipped_{reason}", intent_id=intent_id, source=source)
|
|
257
|
+
|
|
258
|
+
async def _already_bid_recently(self, intent_id: str) -> bool:
|
|
259
|
+
try:
|
|
260
|
+
mine = await self.client.my_bids(self.did, status="open", limit=50)
|
|
261
|
+
for b in mine.get("bids", []):
|
|
262
|
+
if str(b.get("intentId")) == str(intent_id):
|
|
263
|
+
return True
|
|
264
|
+
except Exception:
|
|
265
|
+
return False
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
# ??? Win handling ????????????????????????????????????????????
|
|
269
|
+
async def _check_for_wins(self) -> None:
|
|
270
|
+
try:
|
|
271
|
+
won = await self.client.my_bids(self.did, status="won", limit=50)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
self._emit("error_my_bids_won", error=str(e)[:200])
|
|
274
|
+
return
|
|
275
|
+
for bid in won.get("bids", []):
|
|
276
|
+
bid_id = bid.get("bidId")
|
|
277
|
+
if not bid_id or bid_id in self._state.processed_wins:
|
|
278
|
+
continue
|
|
279
|
+
escrow_id = bid.get("escrowId")
|
|
280
|
+
intent_id = bid.get("intentId")
|
|
281
|
+
if not escrow_id:
|
|
282
|
+
continue
|
|
283
|
+
self._state.processed_wins.add(bid_id)
|
|
284
|
+
try:
|
|
285
|
+
intent = await self.client.get_intent(intent_id) if intent_id else {}
|
|
286
|
+
escrow = await self.client.get_escrow(str(escrow_id))
|
|
287
|
+
self._emit("win_received", bid_id=bid_id, escrow_id=escrow_id, intent_id=intent_id)
|
|
288
|
+
if self.dry_run:
|
|
289
|
+
self._emit("work_skipped_dry_run", bid_id=bid_id)
|
|
290
|
+
continue
|
|
291
|
+
result = await self.do_work(intent, escrow)
|
|
292
|
+
uri = result.get("deliverable_uri")
|
|
293
|
+
if not uri:
|
|
294
|
+
raise ValueError("do_work must return deliverable_uri")
|
|
295
|
+
await self.client.submit_proof(
|
|
296
|
+
str(escrow_id),
|
|
297
|
+
seller_did=self.did,
|
|
298
|
+
proof_uri=uri,
|
|
299
|
+
proof_hash=result.get("deliverable_hash"),
|
|
300
|
+
)
|
|
301
|
+
self._state.wins_today += 1
|
|
302
|
+
self._state.revenue_today_cents += int(bid.get("amountCents") or 0)
|
|
303
|
+
self._emit("proof_submitted", bid_id=bid_id, escrow_id=escrow_id, uri=uri)
|
|
304
|
+
except Exception as e:
|
|
305
|
+
self._emit("error_do_work", bid_id=bid_id, error=str(e)[:300])
|
|
306
|
+
|
|
307
|
+
# ??? Housekeeping ????????????????????????????????????????????
|
|
308
|
+
def _allow_rate(self) -> bool:
|
|
309
|
+
now = time.time()
|
|
310
|
+
cutoff = now - 3600
|
|
311
|
+
while self._state.rate_events and self._state.rate_events[0] < cutoff:
|
|
312
|
+
self._state.rate_events.popleft()
|
|
313
|
+
if len(self._state.rate_events) >= self.rate_limit_per_hour:
|
|
314
|
+
return False
|
|
315
|
+
self._state.rate_events.append(now)
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
def _maybe_roll_day(self) -> None:
|
|
319
|
+
from datetime import datetime, timezone
|
|
320
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
321
|
+
if self._state.day_anchor != today:
|
|
322
|
+
if self._state.day_anchor:
|
|
323
|
+
self._emit("day_rollover", from_day=self._state.day_anchor, to_day=today,
|
|
324
|
+
final_bids=self._state.bids_today, final_wins=self._state.wins_today,
|
|
325
|
+
final_revenue_cents=self._state.revenue_today_cents)
|
|
326
|
+
self._state.day_anchor = today
|
|
327
|
+
self._state.bids_today = 0
|
|
328
|
+
self._state.wins_today = 0
|
|
329
|
+
self._state.revenue_today_cents = 0
|
|
330
|
+
|
|
331
|
+
def _first_session_warning(self) -> None:
|
|
332
|
+
if self.max_bid_usd is None and self.daily_cap_usd is None:
|
|
333
|
+
logger.warning(
|
|
334
|
+
"Synmerco autonomous loop active with NO bid caps. "
|
|
335
|
+
"Set max_bid_usd / daily_cap_usd if needed."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def _emit(self, event_type: str, **fields: Any) -> None:
|
|
339
|
+
from datetime import datetime, timezone
|
|
340
|
+
record = {"ts": datetime.now(timezone.utc).isoformat(), "type": event_type, **fields}
|
|
341
|
+
if self._on_event:
|
|
342
|
+
try:
|
|
343
|
+
self._on_event(record)
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
else:
|
|
347
|
+
logger.info(json.dumps(record, default=str))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
__all__ = ["AsyncSynmercoAutonomousAgent"]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Synmerco Python SDK — async-first HTTP client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Identity(BaseModel):
|
|
10
|
+
did: str
|
|
11
|
+
public_key: str
|
|
12
|
+
created_at: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EscrowSummary(BaseModel):
|
|
16
|
+
escrow_id: str
|
|
17
|
+
state: str
|
|
18
|
+
buyer_did: str
|
|
19
|
+
seller_did: str
|
|
20
|
+
amount_cents: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReputationReport(BaseModel):
|
|
24
|
+
did: str
|
|
25
|
+
score: float
|
|
26
|
+
total_events: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KycStatus(BaseModel):
|
|
30
|
+
did: str
|
|
31
|
+
status: str | None
|
|
32
|
+
kyc_required: bool
|
|
33
|
+
gate_allowed: bool
|
|
34
|
+
gate_reason: str
|
|
35
|
+
lifetime_funded_cents: int
|
|
36
|
+
threshold_cents: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DisputeSummary(BaseModel):
|
|
40
|
+
dispute_id: str
|
|
41
|
+
phase: str
|
|
42
|
+
tier: int
|
|
43
|
+
ruling: str | None = None
|
|
44
|
+
escrow_ruling: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CheckReport(BaseModel):
|
|
48
|
+
check_index: int
|
|
49
|
+
kind: str
|
|
50
|
+
passed: bool
|
|
51
|
+
reason: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SynmercoClient:
|
|
55
|
+
"""Async client for the Synmerco API."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
base_url: str = "http://localhost:3001",
|
|
60
|
+
api_key: str | None = None,
|
|
61
|
+
timeout: float = 30.0,
|
|
62
|
+
) -> None:
|
|
63
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
64
|
+
if api_key:
|
|
65
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
66
|
+
self._http = httpx.AsyncClient(
|
|
67
|
+
base_url=base_url,
|
|
68
|
+
headers=headers,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def close(self) -> None:
|
|
73
|
+
await self._http.aclose()
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> SynmercoClient:
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
79
|
+
await self.close()
|
|
80
|
+
|
|
81
|
+
# ── Identity ─────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async def create_identity(self, did: str, public_key: str) -> Identity:
|
|
84
|
+
r = await self._http.post("/v1/identities", json={"did": did, "publicKey": public_key})
|
|
85
|
+
r.raise_for_status()
|
|
86
|
+
return Identity(**r.json())
|
|
87
|
+
|
|
88
|
+
async def get_identity(self, did: str) -> Identity:
|
|
89
|
+
r = await self._http.get(f"/v1/identities/{did}")
|
|
90
|
+
r.raise_for_status()
|
|
91
|
+
return Identity(**r.json())
|
|
92
|
+
|
|
93
|
+
# ── Reputation ───────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
async def get_reputation(self, did: str) -> ReputationReport:
|
|
96
|
+
r = await self._http.get(f"/v1/reputation/{did}")
|
|
97
|
+
r.raise_for_status()
|
|
98
|
+
return ReputationReport(**r.json())
|
|
99
|
+
|
|
100
|
+
# ── KYC ──────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async def get_kyc_status(self, did: str) -> KycStatus:
|
|
103
|
+
r = await self._http.get("/v1/kyc/status", params={"did": did})
|
|
104
|
+
r.raise_for_status()
|
|
105
|
+
return KycStatus(**r.json())
|
|
106
|
+
|
|
107
|
+
async def create_kyc_session(self, did: str) -> dict[str, Any]:
|
|
108
|
+
r = await self._http.post("/v1/kyc/sessions", json={"did": did})
|
|
109
|
+
r.raise_for_status()
|
|
110
|
+
return r.json()
|
|
111
|
+
|
|
112
|
+
# ── Disputes ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
async def raise_dispute(
|
|
115
|
+
self, escrow_id: str, raised_by: str, respondent: str, reason: str
|
|
116
|
+
) -> DisputeSummary:
|
|
117
|
+
r = await self._http.post("/v1/disputes", json={
|
|
118
|
+
"escrowId": escrow_id,
|
|
119
|
+
"raisedBy": raised_by,
|
|
120
|
+
"respondent": respondent,
|
|
121
|
+
"reason": reason,
|
|
122
|
+
})
|
|
123
|
+
r.raise_for_status()
|
|
124
|
+
data = r.json()
|
|
125
|
+
return DisputeSummary(
|
|
126
|
+
dispute_id=data["disputeId"],
|
|
127
|
+
phase=data["phase"],
|
|
128
|
+
tier=data["tier"],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def get_dispute(self, dispute_id: str) -> dict[str, Any]:
|
|
132
|
+
r = await self._http.get(f"/v1/disputes/{dispute_id}")
|
|
133
|
+
r.raise_for_status()
|
|
134
|
+
return r.json()
|
|
135
|
+
|
|
136
|
+
async def submit_evidence(
|
|
137
|
+
self, dispute_id: str, actor: str, evidence_hash: str, evidence_uri: str
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
r = await self._http.post(f"/v1/disputes/{dispute_id}/evidence", json={
|
|
140
|
+
"actor": actor,
|
|
141
|
+
"evidenceHash": evidence_hash,
|
|
142
|
+
"evidenceUri": evidence_uri,
|
|
143
|
+
})
|
|
144
|
+
r.raise_for_status()
|
|
145
|
+
return r.json()
|
|
146
|
+
|
|
147
|
+
async def dispute_action(
|
|
148
|
+
self, dispute_id: str, action: str, **kwargs: Any
|
|
149
|
+
) -> DisputeSummary:
|
|
150
|
+
body: dict[str, Any] = {"action": action, **kwargs}
|
|
151
|
+
r = await self._http.post(f"/v1/disputes/{dispute_id}/action", json=body)
|
|
152
|
+
r.raise_for_status()
|
|
153
|
+
data = r.json()
|
|
154
|
+
return DisputeSummary(
|
|
155
|
+
dispute_id=data["disputeId"],
|
|
156
|
+
phase=data["phase"],
|
|
157
|
+
tier=data["tier"],
|
|
158
|
+
ruling=data.get("ruling"),
|
|
159
|
+
escrow_ruling=data.get("escrowRuling"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# ── Intents & Bidding ────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
async def list_intents(self, capability: str | None = None, limit: int = 20) -> dict[str, Any]:
|
|
165
|
+
"""Browse open intents (jobs other agents are looking to hire for)."""
|
|
166
|
+
params: dict[str, Any] = {"limit": limit}
|
|
167
|
+
if capability:
|
|
168
|
+
params["capability"] = capability
|
|
169
|
+
r = await self._http.get("/v1/intents", params=params)
|
|
170
|
+
r.raise_for_status()
|
|
171
|
+
return r.json()
|
|
172
|
+
|
|
173
|
+
async def get_intent(self, intent_id: str) -> dict[str, Any]:
|
|
174
|
+
"""Get a single intent with its top 5 open bids and total bid count."""
|
|
175
|
+
r = await self._http.get(f"/v1/intents/{intent_id}")
|
|
176
|
+
r.raise_for_status()
|
|
177
|
+
return r.json()
|
|
178
|
+
|
|
179
|
+
async def broadcast_intent(
|
|
180
|
+
self,
|
|
181
|
+
requester_did: str,
|
|
182
|
+
description: str,
|
|
183
|
+
capability: str | None = None,
|
|
184
|
+
budget_cents: int | None = None,
|
|
185
|
+
min_trust_score: int = 0,
|
|
186
|
+
deadline_hours: int = 72,
|
|
187
|
+
auto_escrow: bool = False,
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""Broadcast an intent. The matcher auto-notifies qualified agents."""
|
|
190
|
+
body: dict[str, Any] = {
|
|
191
|
+
"requesterDid": requester_did,
|
|
192
|
+
"description": description,
|
|
193
|
+
"minTrustScore": min_trust_score,
|
|
194
|
+
"deadlineHours": deadline_hours,
|
|
195
|
+
"autoEscrow": auto_escrow,
|
|
196
|
+
}
|
|
197
|
+
if capability:
|
|
198
|
+
body["capability"] = capability
|
|
199
|
+
if budget_cents is not None:
|
|
200
|
+
body["budgetCents"] = budget_cents
|
|
201
|
+
r = await self._http.post("/v1/intents", json=body)
|
|
202
|
+
r.raise_for_status()
|
|
203
|
+
return r.json()
|
|
204
|
+
|
|
205
|
+
async def submit_bid(
|
|
206
|
+
self,
|
|
207
|
+
intent_id: str,
|
|
208
|
+
bidder_did: str,
|
|
209
|
+
amount_cents: int,
|
|
210
|
+
message: str | None = None,
|
|
211
|
+
expires_at: str | None = None,
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""Submit a competitive bid on an open intent."""
|
|
214
|
+
body: dict[str, Any] = {"bidderDid": bidder_did, "amountCents": amount_cents}
|
|
215
|
+
if message:
|
|
216
|
+
body["message"] = message
|
|
217
|
+
if expires_at:
|
|
218
|
+
body["expiresAt"] = expires_at
|
|
219
|
+
r = await self._http.post(f"/v1/intents/{intent_id}/bids", json=body)
|
|
220
|
+
r.raise_for_status()
|
|
221
|
+
return r.json()
|
|
222
|
+
|
|
223
|
+
async def list_bids_on_intent(
|
|
224
|
+
self,
|
|
225
|
+
intent_id: str,
|
|
226
|
+
status: str = "open",
|
|
227
|
+
limit: int = 50,
|
|
228
|
+
offset: int = 0,
|
|
229
|
+
) -> dict[str, Any]:
|
|
230
|
+
"""List bids on a given intent. Each bid includes the bidder's SynmercoScore."""
|
|
231
|
+
params = {"status": status, "limit": limit, "offset": offset}
|
|
232
|
+
r = await self._http.get(f"/v1/intents/{intent_id}/bids", params=params)
|
|
233
|
+
r.raise_for_status()
|
|
234
|
+
return r.json()
|
|
235
|
+
|
|
236
|
+
async def my_bids(
|
|
237
|
+
self,
|
|
238
|
+
did: str,
|
|
239
|
+
status: str | None = None,
|
|
240
|
+
limit: int = 50,
|
|
241
|
+
offset: int = 0,
|
|
242
|
+
) -> dict[str, Any]:
|
|
243
|
+
"""List your agent's own bids across all intents."""
|
|
244
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
245
|
+
if status:
|
|
246
|
+
params["status"] = status
|
|
247
|
+
r = await self._http.get(f"/v1/agents/{did}/bids", params=params)
|
|
248
|
+
r.raise_for_status()
|
|
249
|
+
return r.json()
|
|
250
|
+
|
|
251
|
+
async def award_intent(self, intent_id: str, bid_id: int) -> dict[str, Any]:
|
|
252
|
+
"""As intent requester, award the intent to a winning bid. Auto-creates escrow."""
|
|
253
|
+
r = await self._http.post(f"/v1/intents/{intent_id}/award", json={"bidId": bid_id})
|
|
254
|
+
r.raise_for_status()
|
|
255
|
+
return r.json()
|
|
256
|
+
|
|
257
|
+
async def withdraw_bid(self, intent_id: str, bid_id: int) -> dict[str, Any]:
|
|
258
|
+
"""Withdraw your own open bid before it's awarded."""
|
|
259
|
+
r = await self._http.delete(f"/v1/intents/{intent_id}/bids/{bid_id}")
|
|
260
|
+
r.raise_for_status()
|
|
261
|
+
return r.json()
|
|
262
|
+
|
|
263
|
+
# ── Predictive Trust ─────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
async def predict_deal(self, buyer_did: str, seller_did: str) -> dict[str, Any]:
|
|
266
|
+
"""Predictive Trust: estimate the likely outcome of an escrow between two parties."""
|
|
267
|
+
r = await self._http.get(f"/v1/predict/{buyer_did}/{seller_did}")
|
|
268
|
+
r.raise_for_status()
|
|
269
|
+
return r.json()
|
|
270
|
+
|
|
271
|
+
# ── Wallet & Score ───────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
async def get_wallet(self, did: str) -> dict[str, Any]:
|
|
274
|
+
"""Get wallet balance for an agent DID."""
|
|
275
|
+
r = await self._http.get(f"/v1/wallets/{did}")
|
|
276
|
+
r.raise_for_status()
|
|
277
|
+
return r.json()
|
|
278
|
+
|
|
279
|
+
async def get_score(self, did: str) -> dict[str, Any]:
|
|
280
|
+
"""Get SynmercoScore for any agent."""
|
|
281
|
+
r = await self._http.get(f"/v1/score/{did}")
|
|
282
|
+
r.raise_for_status()
|
|
283
|
+
return r.json()
|
|
284
|
+
|
|
285
|
+
# ── Escrows & Inbox ──────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
async def get_escrow(self, escrow_id: str) -> dict[str, Any]:
|
|
288
|
+
"""Get escrow details."""
|
|
289
|
+
r = await self._http.get(f"/v1/escrows/{escrow_id}")
|
|
290
|
+
r.raise_for_status()
|
|
291
|
+
return r.json()
|
|
292
|
+
|
|
293
|
+
async def submit_proof(
|
|
294
|
+
self,
|
|
295
|
+
escrow_id: str,
|
|
296
|
+
seller_did: str,
|
|
297
|
+
proof_uri: str,
|
|
298
|
+
proof_hash: str | None = None,
|
|
299
|
+
) -> dict[str, Any]:
|
|
300
|
+
"""Submit proof of work for an escrow."""
|
|
301
|
+
import hashlib
|
|
302
|
+
if not proof_hash:
|
|
303
|
+
h = hashlib.sha256(proof_uri.encode()).hexdigest()
|
|
304
|
+
proof_hash = f"sha256:{h}"
|
|
305
|
+
body = {"sellerDid": seller_did, "proofUri": proof_uri, "proofHash": proof_hash}
|
|
306
|
+
r = await self._http.post(f"/v1/escrows/{escrow_id}/submit-proof", json=body)
|
|
307
|
+
r.raise_for_status()
|
|
308
|
+
return r.json()
|
|
309
|
+
|
|
310
|
+
async def get_inbox(self, did: str) -> list[dict[str, Any]]:
|
|
311
|
+
"""Check your inbox."""
|
|
312
|
+
r = await self._http.get(f"/v1/doorbell/inbox/{did}")
|
|
313
|
+
r.raise_for_status()
|
|
314
|
+
return r.json()
|
|
315
|
+
|
|
316
|
+
# ── Health ───────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
async def health(self) -> dict[str, str]:
|
|
319
|
+
r = await self._http.get("/health")
|
|
320
|
+
r.raise_for_status()
|
|
321
|
+
return r.json()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import respx
|
|
3
|
+
import httpx
|
|
4
|
+
from synmerco import SynmercoClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def client():
|
|
9
|
+
return SynmercoClient(base_url="http://test.local", api_key="sk_test")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.asyncio
|
|
13
|
+
async def test_health(client):
|
|
14
|
+
with respx.mock(base_url="http://test.local") as mock:
|
|
15
|
+
mock.get("/health").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
16
|
+
r = await client.health()
|
|
17
|
+
assert r["status"] == "ok"
|
|
18
|
+
await client.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.asyncio
|
|
22
|
+
async def test_create_identity(client):
|
|
23
|
+
with respx.mock(base_url="http://test.local") as mock:
|
|
24
|
+
mock.post("/v1/identities").mock(return_value=httpx.Response(201, json={
|
|
25
|
+
"did": "did:key:abc", "public_key": "pk123", "created_at": "2026-01-01T00:00:00Z"
|
|
26
|
+
}))
|
|
27
|
+
r = await client.create_identity("did:key:abc", "pk123")
|
|
28
|
+
assert r.did == "did:key:abc"
|
|
29
|
+
await client.close()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_get_reputation(client):
|
|
34
|
+
with respx.mock(base_url="http://test.local") as mock:
|
|
35
|
+
mock.get("/v1/reputation/did:key:abc").mock(return_value=httpx.Response(200, json={
|
|
36
|
+
"did": "did:key:abc", "score": 0.85, "total_events": 10
|
|
37
|
+
}))
|
|
38
|
+
r = await client.get_reputation("did:key:abc")
|
|
39
|
+
assert r.score == 0.85
|
|
40
|
+
assert r.total_events == 10
|
|
41
|
+
await client.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_get_kyc_status(client):
|
|
46
|
+
with respx.mock(base_url="http://test.local") as mock:
|
|
47
|
+
mock.get("/v1/kyc/status").mock(return_value=httpx.Response(200, json={
|
|
48
|
+
"did": "did:key:abc", "status": "verified", "kyc_required": False,
|
|
49
|
+
"gate_allowed": True, "gate_reason": "kyc_verified",
|
|
50
|
+
"lifetime_funded_cents": 150000, "threshold_cents": 99900,
|
|
51
|
+
}))
|
|
52
|
+
r = await client.get_kyc_status("did:key:abc")
|
|
53
|
+
assert r.gate_allowed is True
|
|
54
|
+
assert r.status == "verified"
|
|
55
|
+
await client.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.asyncio
|
|
59
|
+
async def test_raise_dispute(client):
|
|
60
|
+
with respx.mock(base_url="http://test.local") as mock:
|
|
61
|
+
mock.post("/v1/disputes").mock(return_value=httpx.Response(201, json={
|
|
62
|
+
"disputeId": "dsp_001", "phase": "evidence_submission", "tier": 1
|
|
63
|
+
}))
|
|
64
|
+
r = await client.raise_dispute("esc_001", "did:key:buyer", "did:key:seller", "Not delivered")
|
|
65
|
+
assert r.dispute_id == "dsp_001"
|
|
66
|
+
assert r.phase == "evidence_submission"
|
|
67
|
+
await client.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_dispute_action(client):
|
|
72
|
+
with respx.mock(base_url="http://test.local") as mock:
|
|
73
|
+
mock.post("/v1/disputes/dsp_001/action").mock(return_value=httpx.Response(200, json={
|
|
74
|
+
"disputeId": "dsp_001", "phase": "resolved", "tier": 1,
|
|
75
|
+
"ruling": "raiser_wins", "escrowRuling": "court_ruling_buyer_wins"
|
|
76
|
+
}))
|
|
77
|
+
r = await client.dispute_action("dsp_001", "finalize")
|
|
78
|
+
assert r.ruling == "raiser_wins"
|
|
79
|
+
await client.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_context_manager():
|
|
84
|
+
async with SynmercoClient(base_url="http://test.local") as client:
|
|
85
|
+
with respx.mock(base_url="http://test.local") as mock:
|
|
86
|
+
mock.get("/health").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
87
|
+
r = await client.health()
|
|
88
|
+
assert r["status"] == "ok"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_api_key_header(client):
|
|
93
|
+
with respx.mock(base_url="http://test.local") as mock:
|
|
94
|
+
route = mock.get("/health").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
95
|
+
await client.health()
|
|
96
|
+
assert route.calls[0].request.headers["authorization"] == "Bearer sk_test"
|
|
97
|
+
await client.close()
|