aztea 1.0.0__py3-none-any.whl

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.
aztea/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ """
2
+ aztea — Python SDK for the Aztea platform.
3
+
4
+ Quick start
5
+ -----------
6
+ Hire an agent::
7
+
8
+ from aztea import AzteaClient
9
+
10
+ client = AzteaClient(api_key="az_...", base_url="https://yourplatform.com")
11
+ result = client.hire("agent-id", {"url": "https://example.com"})
12
+ print(result.output)
13
+
14
+ Register and run your own agent::
15
+
16
+ from aztea import AgentServer, InputError, ClarificationNeeded
17
+
18
+ server = AgentServer(api_key="az_...", base_url="https://yourplatform.com",
19
+ name="My Agent", price_per_call_usd=0.01, ...)
20
+
21
+ @server.handler
22
+ def handle(input: dict) -> dict:
23
+ if "required_field" not in input:
24
+ raise InputError("'required_field' is missing.") # 80% refund to caller
25
+ if input.get("ambiguous"):
26
+ raise ClarificationNeeded("Which format do you want: JSON or CSV?")
27
+ return {"answer": 42}
28
+
29
+ server.run()
30
+ """
31
+
32
+ __version__ = "0.2.0"
33
+
34
+ from .agent import AgentServer, CallbackReceiver, verify_callback_signature
35
+ from .client import AzteaClient, AsyncAzteaClient
36
+ from .exceptions import (
37
+ AzteaError,
38
+ AgentNotFoundError,
39
+ AuthenticationError,
40
+ ClarificationNeeded,
41
+ ClarificationNeededError,
42
+ ContractVerificationError,
43
+ InputError,
44
+ InsufficientFundsError,
45
+ JobFailedError,
46
+ PermissionError,
47
+ RateLimitError,
48
+ )
49
+ from .models import (
50
+ Agent,
51
+ Job,
52
+ JobResult,
53
+ Transaction,
54
+ VerificationContract,
55
+ Wallet,
56
+ )
57
+
58
+ __all__ = [
59
+ # Main classes
60
+ "AzteaClient",
61
+ "AsyncAzteaClient",
62
+ "AgentServer",
63
+ "CallbackReceiver",
64
+ "verify_callback_signature",
65
+ # Models
66
+ "Agent",
67
+ "Job",
68
+ "JobResult",
69
+ "Transaction",
70
+ "VerificationContract",
71
+ "Wallet",
72
+ # Exceptions — caller side
73
+ "AzteaError",
74
+ "AgentNotFoundError",
75
+ "AuthenticationError",
76
+ "ClarificationNeededError",
77
+ "ContractVerificationError",
78
+ "InsufficientFundsError",
79
+ "JobFailedError",
80
+ "PermissionError",
81
+ "RateLimitError",
82
+ # Exceptions — agent/server side (raise from @server.handler)
83
+ "ClarificationNeeded",
84
+ "InputError",
85
+ ]
aztea/agent.py ADDED
@@ -0,0 +1,522 @@
1
+ """
2
+ agent.py — AgentServer: decorator-based worker that registers an agent and
3
+ polls the marketplace for jobs.
4
+
5
+ Usage
6
+ -----
7
+ from aztea import AgentServer
8
+
9
+ server = AgentServer(
10
+ api_key="az_...",
11
+ name="Data Extractor",
12
+ description="Extracts structured company data from URLs",
13
+ price_per_call_usd=0.10,
14
+ input_schema={"url": {"type": "string"}},
15
+ output_schema={"company_name": {"type": "string"}, "founded_year": {"type": "number"}},
16
+ )
17
+
18
+ @server.handler
19
+ def handle(input: dict) -> dict:
20
+ return {"company_name": "Anthropic", "founded_year": 2021}
21
+
22
+ if __name__ == "__main__":
23
+ server.run()
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import hmac
30
+ import json
31
+ import threading
32
+ import time
33
+ from typing import Any, Callable, Dict, List
34
+
35
+ from .client import AzteaClient, _parse_payload
36
+ from .exceptions import AzteaError, ClarificationNeeded, InputError
37
+
38
+ _HEARTBEAT_INTERVAL = 20 # seconds
39
+ _POLL_INTERVAL = 2 # seconds between job-list polls
40
+ _LEASE_SECONDS = 300 # initial lease + heartbeat renewal
41
+
42
+
43
+ def _print_status(msg: str) -> None:
44
+ print(msg, flush=True)
45
+
46
+
47
+ class AgentServer:
48
+ """
49
+ Decorator-based agent worker.
50
+
51
+ Wrap your handler function with ``@server.handler``, then call
52
+ ``server.run()`` to register with the marketplace and start processing
53
+ jobs.
54
+
55
+ Parameters
56
+ ----------
57
+ api_key
58
+ A key with the ``worker`` scope.
59
+ name
60
+ Unique agent name shown in the registry.
61
+ description
62
+ Human-readable description of what the agent does.
63
+ price_per_call_usd
64
+ Price charged per call in USD (e.g. ``0.10`` for 10 cents).
65
+ input_schema
66
+ JSON Schema describing the expected input dict fields.
67
+ output_schema
68
+ JSON Schema describing the output dict fields.
69
+ tags
70
+ Optional list of tag strings for discoverability.
71
+ endpoint_url
72
+ The public URL where the marketplace can make sync calls.
73
+ Defaults to ``http://localhost:{port}``. Must be reachable by the
74
+ server for sync calls; async polling works regardless.
75
+ port
76
+ Port for the optional HTTP server (sync call path).
77
+ base_url
78
+ Aztea server base URL.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ api_key: str,
84
+ name: str,
85
+ description: str,
86
+ price_per_call_usd: float,
87
+ input_schema: Dict[str, Any] | None = None,
88
+ output_schema: Dict[str, Any] | None = None,
89
+ tags: List[str] | None = None,
90
+ endpoint_url: str | None = None,
91
+ port: int = 8080,
92
+ base_url: str = "https://api.aztea.dev",
93
+ ) -> None:
94
+ self._key = api_key
95
+ self.name = name
96
+ self.description = description
97
+ self.price_per_call_usd = price_per_call_usd
98
+ self.input_schema = input_schema or {}
99
+ self.output_schema = output_schema or {}
100
+ self.tags = tags or []
101
+ self._port = port
102
+ self._endpoint_url = endpoint_url or f"http://localhost:{port}"
103
+ self._base_url = base_url
104
+
105
+ self._client = AzteaClient(api_key=api_key, base_url=base_url)
106
+ self._handler_func: Callable[[Dict[str, Any]], Dict[str, Any]] | None = None
107
+ self._agent_id: str | None = None
108
+
109
+ # ── Decorator ─────────────────────────────────────────────────────────────
110
+
111
+ def handler(
112
+ self, func: Callable[[Dict[str, Any]], Dict[str, Any]]
113
+ ) -> Callable[[Dict[str, Any]], Dict[str, Any]]:
114
+ """Register *func* as the job handler. Use as a decorator."""
115
+ self._handler_func = func
116
+ return func
117
+
118
+ # ── Public entry point ────────────────────────────────────────────────────
119
+
120
+ def run(self) -> None:
121
+ """
122
+ Register the agent (or locate the existing one) then start the
123
+ polling loop. Blocks forever; press Ctrl-C to stop.
124
+ """
125
+ if self._handler_func is None:
126
+ raise RuntimeError(
127
+ "No handler registered. Decorate a function with @server.handler."
128
+ )
129
+
130
+ self._register_or_locate()
131
+ _print_status(
132
+ f"[aztea] Agent '{self.name}' (id={self._agent_id}) ready. "
133
+ "Polling for jobs…"
134
+ )
135
+
136
+ try:
137
+ self._poll_forever()
138
+ except KeyboardInterrupt:
139
+ _print_status("[aztea] Shutting down.")
140
+
141
+ # ── Registration ──────────────────────────────────────────────────────────
142
+
143
+ def _register_or_locate(self) -> None:
144
+ """Register the agent; if the name already exists, locate its ID."""
145
+ try:
146
+ data = self._client._request(
147
+ "POST",
148
+ "/registry/register",
149
+ json={
150
+ "name": self.name,
151
+ "description": self.description,
152
+ "endpoint_url": self._endpoint_url,
153
+ "price_per_call_usd": self.price_per_call_usd,
154
+ "tags": self.tags,
155
+ "input_schema": self.input_schema,
156
+ "output_schema": self.output_schema,
157
+ },
158
+ )
159
+ self._agent_id = data["agent_id"]
160
+ _print_status(
161
+ f"[aztea] Registered new agent '{self.name}' → {self._agent_id}"
162
+ )
163
+ except AzteaError as exc:
164
+ # 409 Conflict means the name is already registered under our key
165
+ if exc.status_code == 409:
166
+ self._agent_id = self._locate_existing_agent()
167
+ if self._agent_id is None:
168
+ raise RuntimeError(
169
+ f"Agent name '{self.name}' already taken by a different owner."
170
+ ) from exc
171
+ _print_status(
172
+ f"[aztea] Found existing agent '{self.name}' → {self._agent_id}"
173
+ )
174
+ else:
175
+ raise
176
+
177
+ def _locate_existing_agent(self) -> str | None:
178
+ """Search the registry for an agent with our name owned by our key."""
179
+ try:
180
+ data = self._client._request("GET", "/registry/agents")
181
+ for a in data.get("agents") or []:
182
+ if a.get("name") == self.name:
183
+ return a["agent_id"]
184
+ except AzteaError:
185
+ pass
186
+ return None
187
+
188
+ # ── Polling loop ──────────────────────────────────────────────────────────
189
+
190
+ def _poll_forever(self) -> None:
191
+ while True:
192
+ try:
193
+ data = self._client._request(
194
+ "GET",
195
+ f"/jobs/agent/{self._agent_id}",
196
+ params={"status": "pending", "limit": "10"},
197
+ )
198
+ jobs = data.get("jobs") or [] if isinstance(data, dict) else []
199
+ for job in jobs:
200
+ self._process_job(job)
201
+ except AzteaError as exc:
202
+ _print_status(f"[aztea] Poll error: {exc}")
203
+ except Exception as exc:
204
+ _print_status(f"[aztea] Unexpected error: {exc}")
205
+
206
+ time.sleep(_POLL_INTERVAL)
207
+
208
+ # ── Job processing ────────────────────────────────────────────────────────
209
+
210
+ def _process_job(self, job_raw: Dict[str, Any]) -> None:
211
+ job_id: str = job_raw["job_id"]
212
+
213
+ # Claim the job
214
+ try:
215
+ claim_data = self._client._request(
216
+ "POST",
217
+ f"/jobs/{job_id}/claim",
218
+ json={"lease_seconds": _LEASE_SECONDS},
219
+ )
220
+ except AzteaError:
221
+ # Another worker may have claimed it first — skip silently
222
+ return
223
+ if not isinstance(claim_data, dict):
224
+ _print_status(f"[aztea] Claim response for job {job_id} was malformed.")
225
+ return
226
+ raw_claim_token = claim_data.get("claim_token")
227
+ if not isinstance(raw_claim_token, str) or not raw_claim_token.strip():
228
+ _print_status(f"[aztea] Claim for job {job_id} did not return a valid claim token.")
229
+ return
230
+ claim_token: str = raw_claim_token
231
+
232
+ _print_status(f"[aztea] Claimed job {job_id}")
233
+
234
+ # Heartbeat thread — keeps the lease alive every 20s
235
+ stop_hb = threading.Event()
236
+ hb_thread = threading.Thread(
237
+ target=self._heartbeat_loop,
238
+ args=(job_id, claim_token, stop_hb),
239
+ daemon=True,
240
+ )
241
+ hb_thread.start()
242
+
243
+ # Run handler (with clarification retry support)
244
+ t0 = time.monotonic()
245
+ input_payload = _parse_payload(job_raw.get("input_payload"))
246
+ try:
247
+ output = self._handler_func(input_payload) # type: ignore[misc]
248
+ elapsed = time.monotonic() - t0
249
+ stop_hb.set()
250
+ hb_thread.join(timeout=1)
251
+
252
+ self._client._request(
253
+ "POST",
254
+ f"/jobs/{job_id}/complete",
255
+ json={
256
+ "output_payload": output,
257
+ "claim_token": claim_token,
258
+ },
259
+ )
260
+ _print_status(
261
+ f"[aztea] Completed job {job_id} ({elapsed:.1f}s)"
262
+ )
263
+
264
+ except ClarificationNeeded as exc:
265
+ # Pause the job and ask the caller a question.
266
+ # The heartbeat thread keeps running while we wait.
267
+ _print_status(
268
+ f"[aztea] Job {job_id} needs clarification: {exc.question}"
269
+ )
270
+ try:
271
+ self._client._request(
272
+ "POST",
273
+ f"/jobs/{job_id}/messages",
274
+ json={
275
+ "type": "clarification_request",
276
+ "content": exc.question,
277
+ "claim_token": claim_token,
278
+ },
279
+ )
280
+ except AzteaError:
281
+ pass
282
+
283
+ # Poll for a clarification_response (up to 10 min)
284
+ answer = self._wait_for_clarification(job_id, timeout_seconds=600)
285
+ stop_hb.set()
286
+ hb_thread.join(timeout=1)
287
+
288
+ if answer is None:
289
+ # Timed out waiting — fail with full refund
290
+ try:
291
+ self._client._request(
292
+ "POST",
293
+ f"/jobs/{job_id}/fail",
294
+ json={
295
+ "error_message": "Timed out waiting for caller clarification.",
296
+ "claim_token": claim_token,
297
+ "refund_fraction": 1.0,
298
+ },
299
+ )
300
+ except AzteaError:
301
+ pass
302
+ _print_status(f"[aztea] Job {job_id} timed out awaiting clarification")
303
+ else:
304
+ # Re-run handler with clarification injected
305
+ input_payload["__clarification__"] = answer
306
+ try:
307
+ output = self._handler_func(input_payload) # type: ignore[misc]
308
+ self._client._request(
309
+ "POST",
310
+ f"/jobs/{job_id}/complete",
311
+ json={"output_payload": output, "claim_token": claim_token},
312
+ )
313
+ elapsed = time.monotonic() - t0
314
+ _print_status(f"[aztea] Completed job {job_id} after clarification ({elapsed:.1f}s)")
315
+ except Exception as retry_exc:
316
+ try:
317
+ self._client._request(
318
+ "POST",
319
+ f"/jobs/{job_id}/fail",
320
+ json={
321
+ "error_message": str(retry_exc),
322
+ "claim_token": claim_token,
323
+ "refund_fraction": 1.0,
324
+ },
325
+ )
326
+ except AzteaError:
327
+ pass
328
+ _print_status(f"[aztea] Failed job {job_id} after clarification: {retry_exc}")
329
+
330
+ except InputError as exc:
331
+ # Bad input from caller — fail fast with partial refund.
332
+ elapsed = time.monotonic() - t0
333
+ stop_hb.set()
334
+ hb_thread.join(timeout=1)
335
+ try:
336
+ self._client._request(
337
+ "POST",
338
+ f"/jobs/{job_id}/fail",
339
+ json={
340
+ "error_message": str(exc),
341
+ "claim_token": claim_token,
342
+ "refund_fraction": exc.refund_fraction,
343
+ },
344
+ )
345
+ except AzteaError:
346
+ pass
347
+ _print_status(
348
+ f"[aztea] Job {job_id} rejected (bad input, "
349
+ f"{int(exc.refund_fraction*100)}% refund): {exc}"
350
+ )
351
+
352
+ except Exception as exc:
353
+ elapsed = time.monotonic() - t0
354
+ stop_hb.set()
355
+ hb_thread.join(timeout=1)
356
+
357
+ error_msg = str(exc)
358
+ try:
359
+ self._client._request(
360
+ "POST",
361
+ f"/jobs/{job_id}/fail",
362
+ json={
363
+ "error_message": error_msg,
364
+ "claim_token": claim_token,
365
+ "refund_fraction": 1.0,
366
+ },
367
+ )
368
+ except AzteaError:
369
+ pass
370
+
371
+ _print_status(f"[aztea] Failed job {job_id}: {error_msg}")
372
+
373
+ def _wait_for_clarification(
374
+ self,
375
+ job_id: str,
376
+ timeout_seconds: float = 600,
377
+ ) -> str | None:
378
+ """Poll job messages until a clarification_response arrives or timeout."""
379
+ deadline = time.monotonic() + timeout_seconds
380
+ seen_ids: set[int] = set()
381
+ while time.monotonic() < deadline:
382
+ try:
383
+ data = self._client._request("GET", f"/jobs/{job_id}/messages")
384
+ messages = data.get("messages") or []
385
+ for msg in messages:
386
+ msg_id = msg.get("message_id")
387
+ if msg_id in seen_ids:
388
+ continue
389
+ seen_ids.add(msg_id)
390
+ if msg.get("type") in ("clarification_response", "clarification"):
391
+ content = msg.get("content")
392
+ if isinstance(content, dict):
393
+ return content.get("text") or str(content)
394
+ return str(content) if content is not None else ""
395
+ except AzteaError:
396
+ pass
397
+ time.sleep(5)
398
+ return None
399
+
400
+ def _heartbeat_loop(
401
+ self,
402
+ job_id: str,
403
+ claim_token: str | None,
404
+ stop_event: threading.Event,
405
+ ) -> None:
406
+ """Send periodic heartbeats until stop_event is set."""
407
+ while not stop_event.wait(timeout=_HEARTBEAT_INTERVAL):
408
+ try:
409
+ self._client._request(
410
+ "POST",
411
+ f"/jobs/{job_id}/heartbeat",
412
+ json={
413
+ "lease_seconds": _LEASE_SECONDS,
414
+ "claim_token": claim_token,
415
+ },
416
+ )
417
+ except AzteaError:
418
+ break
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # Standalone callback receiver helper
423
+ # ---------------------------------------------------------------------------
424
+
425
+ def verify_callback_signature(
426
+ body: bytes,
427
+ signature_header: str,
428
+ secret: str,
429
+ ) -> bool:
430
+ """
431
+ Verify an X-Aztea-Signature header value against a raw request body.
432
+
433
+ Parameters
434
+ ----------
435
+ body
436
+ Raw bytes of the POST body.
437
+ signature_header
438
+ Value of the ``X-Aztea-Signature`` header (``sha256=<hex>``).
439
+ secret
440
+ The ``callback_secret`` you set when creating the job.
441
+
442
+ Returns
443
+ -------
444
+ bool
445
+ True if the signature is valid, False otherwise.
446
+ """
447
+ expected = "sha256=" + hmac.new(
448
+ secret.encode("utf-8"), body, hashlib.sha256
449
+ ).hexdigest()
450
+ try:
451
+ return hmac.compare_digest(expected, signature_header)
452
+ except (TypeError, ValueError):
453
+ return False
454
+
455
+
456
+ class CallbackReceiver:
457
+ """
458
+ Lightweight WSGI/ASGI-agnostic helper for receiving job completion
459
+ callbacks from Aztea.
460
+
461
+ Usage (Flask example)::
462
+
463
+ from aztea import CallbackReceiver
464
+
465
+ receiver = CallbackReceiver(secret="my-secret")
466
+
467
+ @receiver.on_job_complete
468
+ def handle_complete(payload: dict) -> None:
469
+ print("Job done:", payload["job_id"], payload["status"])
470
+
471
+ @app.route("/callback", methods=["POST"])
472
+ def callback():
473
+ raw = request.get_data()
474
+ sig = request.headers.get("X-Aztea-Signature", "")
475
+ receiver.dispatch(raw, sig)
476
+ return "", 204
477
+
478
+ Usage (FastAPI example)::
479
+
480
+ from fastapi import Request
481
+ from aztea import CallbackReceiver
482
+
483
+ receiver = CallbackReceiver(secret="my-secret")
484
+
485
+ @receiver.on_job_complete
486
+ def handle_complete(payload: dict) -> None:
487
+ print("Job done:", payload["job_id"])
488
+
489
+ @app.post("/callback")
490
+ async def callback(request: Request):
491
+ raw = await request.body()
492
+ sig = request.headers.get("X-Aztea-Signature", "")
493
+ receiver.dispatch(raw, sig)
494
+ return {}
495
+ """
496
+
497
+ def __init__(self, secret: str) -> None:
498
+ self._secret = secret
499
+ self._handler: Callable[[dict], Any] | None = None
500
+
501
+ def on_job_complete(
502
+ self, func: Callable[[dict], Any]
503
+ ) -> Callable[[dict], Any]:
504
+ """Decorator: register *func* as the handler for completed-job payloads."""
505
+ self._handler = func
506
+ return func
507
+
508
+ def dispatch(self, body: bytes, signature_header: str) -> None:
509
+ """
510
+ Verify the HMAC signature and call the registered handler.
511
+
512
+ Raises
513
+ ------
514
+ ValueError
515
+ If the signature is invalid or no handler is registered.
516
+ """
517
+ if not verify_callback_signature(body, signature_header, self._secret):
518
+ raise ValueError("Invalid X-Aztea-Signature; rejecting callback.")
519
+ if self._handler is None:
520
+ raise ValueError("No handler registered. Use @receiver.on_job_complete.")
521
+ payload = json.loads(body)
522
+ self._handler(payload)