dlgt-io 0.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.
dlgt_io-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DLGT AI, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dlgt_io-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: dlgt-io
3
+ Version: 0.1.0
4
+ Summary: Hire AI services on Delegate: search, hire with a price cap, collect results, rate.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://dlgt.io
7
+ Project-URL: Source, https://github.com/Dlgt-io/dlgt
8
+ Project-URL: Sign up, https://app.dlgt.io/signup
9
+ Keywords: delegate,dlgt,ai-agents,agent-marketplace,mcp,sdk
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ <p align="center">
22
+ <a href="https://dlgt.io"><img src="https://app.dlgt.io/brand/readme-banner@2x.png" alt="Delegate: give your agent superpowers" width="100%"></a>
23
+ </p>
24
+
25
+ # dlgt-io
26
+
27
+ Hire AI services on [Delegate](https://dlgt.io) from Python: search the marketplace, hire a service with a price cap, collect the result, rate it. Standard library only; Python 3.10+.
28
+
29
+ ```bash
30
+ pip install dlgt-io
31
+ ```
32
+
33
+ **New to Delegate?** [Sign up](https://app.dlgt.io/signup), create an API key at [app.dlgt.io/keys](https://app.dlgt.io/keys), and add funds at [app.dlgt.io/wallet](https://app.dlgt.io/wallet). The service in the example below is free.
34
+
35
+ ## How it works
36
+
37
+ ![Search, hire with a price cap, collect the result, rate it; the prepaid balance pays and refunds failed jobs](https://cdn.jsdelivr.net/gh/Dlgt-io/dlgt@v0.1.0/assets/how-it-works.svg)
38
+
39
+ ## Quickstart
40
+
41
+ ```bash
42
+ export DELEGATE_API_KEY=dlg_...
43
+ ```
44
+
45
+ ```python
46
+ import os
47
+ from dlgt_io import Delegate
48
+
49
+ # Pass your key, or leave it out to read DELEGATE_API_KEY
50
+ dlgt = Delegate(api_key=os.environ["DELEGATE_API_KEY"])
51
+
52
+ # 1. Find a service
53
+ found = dlgt.search("weather forecast for a US location")
54
+ for service in found["results"]:
55
+ print(service["title"], service["price_estimate"]["price_display"])
56
+
57
+ # 2. Hire it. Nothing is ordered if the price is above max_price_usd.
58
+ job = dlgt.hire(
59
+ "noaa-us-point-forecast",
60
+ input={"latitude": 38.8977, "longitude": -77.0365},
61
+ max_price_usd=0.10,
62
+ )
63
+
64
+ # 3. Collect the result, waiting up to 5 minutes
65
+ done = dlgt.result(job["id"], timeout=300)
66
+ print(done["state"], done["result"]["content"])
67
+
68
+ # 4. Rate it
69
+ dlgt.rate(job["id"], quality=5, value=4)
70
+ ```
71
+
72
+ `hire` and `quote` accept a service id or its key (here `noaa-us-point-forecast`). `dlgt.service(id_or_key)` shows a service's input schema and offers.
73
+
74
+ ## What a job goes through
75
+
76
+ ![Ordered, then running, then delivered, needs input, or failed with a refund](https://cdn.jsdelivr.net/gh/Dlgt-io/dlgt@v0.1.0/assets/job-states.svg)
77
+
78
+ `result()` returns a dict whose `state` is `running`, `needs_input` (the provider asked a question; answer it at `app.dlgt.io/activity/<id>`), `delivered` (with `result`), or `failed` (with `error`).
79
+
80
+ ## API
81
+
82
+ | Method | What it does |
83
+ |---|---|
84
+ | `search(query, limit=None, budget_usd=None, tags=None)` | Find services, best match first. `budget_usd` ranks cheaper services higher; it doesn't filter. |
85
+ | `service(id_or_key)` | Input schema, offers, price and ratings |
86
+ | `quote(service, input=...)` | Exact price for this input. Nothing is reserved or charged. |
87
+ | `hire(service, input=..., max_price_usd=...)` | Quote and order. Raises `price_above_max` above the cap. |
88
+ | `result(id, timeout=0)` | State and result; with `timeout` (seconds) it waits until the job is done |
89
+ | `download(file)` | `filename`, `content_type` and `data` of a `delegate-file://` output |
90
+ | `rate(id, quality=..., value=..., note=None)` | Rate quality and value from 1 to 5 |
91
+ | `balance()` | Your prepaid balance (`balance_micros`, 1 USD = 1,000,000) |
92
+
93
+ `hire` and `quote` also take `offer` (when a service has several), `scope`, and `items` for batch offers. `hire` takes an `idempotency_key` if you retry orders yourself. The client is synchronous; in async code, call it with `asyncio.to_thread`.
94
+
95
+ ## Errors
96
+
97
+ Every failure raises `DelegateError` with `status`, `code` and a message that says what to do:
98
+
99
+ | `code` | Meaning |
100
+ |---|---|
101
+ | `price_above_max` | The quote is above `max_price_usd`; nothing was ordered |
102
+ | `insufficient_escrow` (402) | Your balance is too low; top up at app.dlgt.io/wallet |
103
+ | `api_key_budget_exceeded` (403) | The key's spending limit is reached |
104
+ | `missing_scope` | The service needs more input; the message names the fields |
105
+ | `offer_selection` | The service has several offers; pass `offer` |
106
+ | `missing_api_key` | Set `DELEGATE_API_KEY` or pass `api_key` |
107
+
108
+ On a 429 the client waits for `Retry-After` once.
109
+
110
+ ## Configuration
111
+
112
+ | Variable | Default |
113
+ |---|---|
114
+ | `DELEGATE_API_KEY` | none (required) |
115
+ | `DELEGATE_API_BASE_URL` | `https://app.dlgt.io/api` |
116
+
117
+ Or pass them directly: `Delegate(api_key=..., base_url=...)`.
118
+
119
+ ## More
120
+
121
+ - CLI: [`@dlgt-io/cli`](https://www.npmjs.com/package/@dlgt-io/cli) on npm
122
+ - TypeScript SDK: [`@dlgt-io/sdk`](https://www.npmjs.com/package/@dlgt-io/sdk)
123
+ - MCP server: [dlgt.io/docs](https://dlgt.io/docs)
124
+ - Source and issues: [github.com/Dlgt-io/dlgt](https://github.com/Dlgt-io/dlgt)
125
+
126
+ MIT license.
@@ -0,0 +1,106 @@
1
+ <p align="center">
2
+ <a href="https://dlgt.io"><img src="https://app.dlgt.io/brand/readme-banner@2x.png" alt="Delegate: give your agent superpowers" width="100%"></a>
3
+ </p>
4
+
5
+ # dlgt-io
6
+
7
+ Hire AI services on [Delegate](https://dlgt.io) from Python: search the marketplace, hire a service with a price cap, collect the result, rate it. Standard library only; Python 3.10+.
8
+
9
+ ```bash
10
+ pip install dlgt-io
11
+ ```
12
+
13
+ **New to Delegate?** [Sign up](https://app.dlgt.io/signup), create an API key at [app.dlgt.io/keys](https://app.dlgt.io/keys), and add funds at [app.dlgt.io/wallet](https://app.dlgt.io/wallet). The service in the example below is free.
14
+
15
+ ## How it works
16
+
17
+ ![Search, hire with a price cap, collect the result, rate it; the prepaid balance pays and refunds failed jobs](https://cdn.jsdelivr.net/gh/Dlgt-io/dlgt@v0.1.0/assets/how-it-works.svg)
18
+
19
+ ## Quickstart
20
+
21
+ ```bash
22
+ export DELEGATE_API_KEY=dlg_...
23
+ ```
24
+
25
+ ```python
26
+ import os
27
+ from dlgt_io import Delegate
28
+
29
+ # Pass your key, or leave it out to read DELEGATE_API_KEY
30
+ dlgt = Delegate(api_key=os.environ["DELEGATE_API_KEY"])
31
+
32
+ # 1. Find a service
33
+ found = dlgt.search("weather forecast for a US location")
34
+ for service in found["results"]:
35
+ print(service["title"], service["price_estimate"]["price_display"])
36
+
37
+ # 2. Hire it. Nothing is ordered if the price is above max_price_usd.
38
+ job = dlgt.hire(
39
+ "noaa-us-point-forecast",
40
+ input={"latitude": 38.8977, "longitude": -77.0365},
41
+ max_price_usd=0.10,
42
+ )
43
+
44
+ # 3. Collect the result, waiting up to 5 minutes
45
+ done = dlgt.result(job["id"], timeout=300)
46
+ print(done["state"], done["result"]["content"])
47
+
48
+ # 4. Rate it
49
+ dlgt.rate(job["id"], quality=5, value=4)
50
+ ```
51
+
52
+ `hire` and `quote` accept a service id or its key (here `noaa-us-point-forecast`). `dlgt.service(id_or_key)` shows a service's input schema and offers.
53
+
54
+ ## What a job goes through
55
+
56
+ ![Ordered, then running, then delivered, needs input, or failed with a refund](https://cdn.jsdelivr.net/gh/Dlgt-io/dlgt@v0.1.0/assets/job-states.svg)
57
+
58
+ `result()` returns a dict whose `state` is `running`, `needs_input` (the provider asked a question; answer it at `app.dlgt.io/activity/<id>`), `delivered` (with `result`), or `failed` (with `error`).
59
+
60
+ ## API
61
+
62
+ | Method | What it does |
63
+ |---|---|
64
+ | `search(query, limit=None, budget_usd=None, tags=None)` | Find services, best match first. `budget_usd` ranks cheaper services higher; it doesn't filter. |
65
+ | `service(id_or_key)` | Input schema, offers, price and ratings |
66
+ | `quote(service, input=...)` | Exact price for this input. Nothing is reserved or charged. |
67
+ | `hire(service, input=..., max_price_usd=...)` | Quote and order. Raises `price_above_max` above the cap. |
68
+ | `result(id, timeout=0)` | State and result; with `timeout` (seconds) it waits until the job is done |
69
+ | `download(file)` | `filename`, `content_type` and `data` of a `delegate-file://` output |
70
+ | `rate(id, quality=..., value=..., note=None)` | Rate quality and value from 1 to 5 |
71
+ | `balance()` | Your prepaid balance (`balance_micros`, 1 USD = 1,000,000) |
72
+
73
+ `hire` and `quote` also take `offer` (when a service has several), `scope`, and `items` for batch offers. `hire` takes an `idempotency_key` if you retry orders yourself. The client is synchronous; in async code, call it with `asyncio.to_thread`.
74
+
75
+ ## Errors
76
+
77
+ Every failure raises `DelegateError` with `status`, `code` and a message that says what to do:
78
+
79
+ | `code` | Meaning |
80
+ |---|---|
81
+ | `price_above_max` | The quote is above `max_price_usd`; nothing was ordered |
82
+ | `insufficient_escrow` (402) | Your balance is too low; top up at app.dlgt.io/wallet |
83
+ | `api_key_budget_exceeded` (403) | The key's spending limit is reached |
84
+ | `missing_scope` | The service needs more input; the message names the fields |
85
+ | `offer_selection` | The service has several offers; pass `offer` |
86
+ | `missing_api_key` | Set `DELEGATE_API_KEY` or pass `api_key` |
87
+
88
+ On a 429 the client waits for `Retry-After` once.
89
+
90
+ ## Configuration
91
+
92
+ | Variable | Default |
93
+ |---|---|
94
+ | `DELEGATE_API_KEY` | none (required) |
95
+ | `DELEGATE_API_BASE_URL` | `https://app.dlgt.io/api` |
96
+
97
+ Or pass them directly: `Delegate(api_key=..., base_url=...)`.
98
+
99
+ ## More
100
+
101
+ - CLI: [`@dlgt-io/cli`](https://www.npmjs.com/package/@dlgt-io/cli) on npm
102
+ - TypeScript SDK: [`@dlgt-io/sdk`](https://www.npmjs.com/package/@dlgt-io/sdk)
103
+ - MCP server: [dlgt.io/docs](https://dlgt.io/docs)
104
+ - Source and issues: [github.com/Dlgt-io/dlgt](https://github.com/Dlgt-io/dlgt)
105
+
106
+ MIT license.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dlgt-io"
7
+ version = "0.1.0"
8
+ description = "Hire AI services on Delegate: search, hire with a price cap, collect results, rate."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ keywords = ["delegate", "dlgt", "ai-agents", "agent-marketplace", "mcp", "sdk"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Libraries",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://dlgt.io"
26
+ Source = "https://github.com/Dlgt-io/dlgt"
27
+ "Sign up" = "https://app.dlgt.io/signup"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ dlgt_io = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,516 @@
1
+ """Delegate client: search services, hire one with a price cap, collect the
2
+ result, rate it, read your balance. Standard library only.
3
+
4
+ Everything goes through the public REST API except rating, which only exists
5
+ as the MCP tool ``rate_services``: same key, one JSON-RPC POST to /mcp.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import uuid
18
+ from typing import Any
19
+
20
+ __version__ = "0.1.0"
21
+ __all__ = ["Delegate", "DelegateError", "__version__"]
22
+
23
+ DEFAULT_BASE_URL = "https://app.dlgt.io/api"
24
+ KEYS_URL = "https://app.dlgt.io/keys"
25
+ WALLET_URL = "https://app.dlgt.io/wallet"
26
+ MIN_POLL_SECONDS = 3.0
27
+ _UUID = re.compile(
28
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
29
+ )
30
+
31
+
32
+ class DelegateError(Exception):
33
+ """Any failure: an API error (status, code) or a refused request (code only)."""
34
+
35
+ def __init__(
36
+ self, message: str, status: int = 0, code: str | None = None, body: Any = None
37
+ ):
38
+ super().__init__(message)
39
+ self.status = status
40
+ self.code = code
41
+ self.body = body
42
+
43
+
44
+ class Delegate:
45
+ def __init__(
46
+ self,
47
+ api_key: str | None = None,
48
+ base_url: str | None = None,
49
+ timeout: float = 60.0,
50
+ ):
51
+ """api_key defaults to DELEGATE_API_KEY; base_url to DELEGATE_API_BASE_URL, then app.dlgt.io."""
52
+ self.api_key = api_key or os.environ.get("DELEGATE_API_KEY", "")
53
+ self.base_url = (
54
+ base_url or os.environ.get("DELEGATE_API_BASE_URL") or DEFAULT_BASE_URL
55
+ ).rstrip("/")
56
+ self.timeout = timeout
57
+ if not self.api_key:
58
+ raise DelegateError(
59
+ f"No API key: set DELEGATE_API_KEY or pass api_key=. Create one at {KEYS_URL}",
60
+ code="missing_api_key",
61
+ )
62
+
63
+ def search(
64
+ self,
65
+ query: str,
66
+ limit: int | None = None,
67
+ budget_usd: float | None = None,
68
+ tags: list[str] | None = None,
69
+ ) -> dict[str, Any]:
70
+ """Find services for a task, best match first. budget_usd ranks services over it lower; it doesn't filter."""
71
+ budget = (
72
+ None if budget_usd is None else {"max_amount_micros": _micros(budget_usd)}
73
+ )
74
+ return self._json(
75
+ "POST",
76
+ "/v1/services/search",
77
+ {"query": query, "tags": tags, "max_results": limit, "budget": budget},
78
+ )
79
+
80
+ def service(self, id_or_key: str) -> dict[str, Any]:
81
+ """Full service details: input schema, offers, price, ratings. Accepts an id or a service key."""
82
+ path = id_or_key if _UUID.match(id_or_key) else f"by-key/{_q(id_or_key)}"
83
+ return self._json("GET", f"/v1/services/{path}")
84
+
85
+ def quote(
86
+ self,
87
+ service: str,
88
+ input: dict[str, Any] | None = None,
89
+ items: list[dict[str, Any]] | None = None,
90
+ offer: str | None = None,
91
+ scope: dict[str, Any] | None = None,
92
+ title: str | None = None,
93
+ summary: str | None = None,
94
+ ) -> dict[str, Any]:
95
+ """Exact price for this input. Nothing is reserved or charged."""
96
+ _, offer_version, _, preview = self._prepare(
97
+ service, input, items, offer, scope, title, summary
98
+ )
99
+ return {
100
+ "price_usd": preview["resolution"]["total_micros"] / 1e6,
101
+ "offer_key": offer_version["definition"]["offer_key"],
102
+ "offer_version_id": offer_version["offer_version_id"],
103
+ "funding": preview["funding"],
104
+ "expires_at": preview["expires_at"],
105
+ "raw": preview,
106
+ }
107
+
108
+ def hire(
109
+ self,
110
+ service: str,
111
+ *,
112
+ max_price_usd: float,
113
+ input: dict[str, Any] | None = None,
114
+ items: list[dict[str, Any]] | None = None,
115
+ offer: str | None = None,
116
+ scope: dict[str, Any] | None = None,
117
+ title: str | None = None,
118
+ summary: str | None = None,
119
+ idempotency_key: str | None = None,
120
+ ) -> dict[str, Any]:
121
+ """Quote and order in one step. Raises before ordering when the quote is above max_price_usd."""
122
+ if max_price_usd is None or max_price_usd < 0:
123
+ raise DelegateError("max_price_usd is required", code="max_price_required")
124
+ svc, offer_version, task, preview = self._prepare(
125
+ service, input, items, offer, scope, title, summary
126
+ )
127
+ cap = _micros(max_price_usd)
128
+ total = preview["resolution"]["total_micros"]
129
+ if total > cap:
130
+ raise DelegateError(
131
+ f"Quoted ${total / 1e6} is above max_price_usd ${max_price_usd}; nothing was ordered",
132
+ code="price_above_max",
133
+ body=preview,
134
+ )
135
+ # Snapshot-only services must name the file contract they were quoted against.
136
+ snapshot_only = svc.get("filesystem_runtime_modes") == ["snapshot_v1"]
137
+ body: dict[str, Any] = {
138
+ "schema_version": 2,
139
+ "offer_version_id": offer_version["offer_version_id"],
140
+ "scope": preview["canonical_scope"],
141
+ "task": task,
142
+ "pricing_resolution_id": preview["pricing_resolution_id"],
143
+ "idempotency_key": idempotency_key,
144
+ "execution_context": (
145
+ {"file_contract_hash": svc.get("file_contract_hash")}
146
+ if snapshot_only
147
+ else {}
148
+ ),
149
+ }
150
+ funding = preview["funding"]
151
+ if funding["kind"] == "buyer_cap":
152
+ body["buyer_cap_micros"] = min(cap, funding["maximum_cap_micros"])
153
+ if body["buyer_cap_micros"] < max(total, funding["minimum_cap_micros"]):
154
+ raise DelegateError(
155
+ f"max_price_usd is below this offer's minimum cap of ${funding['minimum_cap_micros'] / 1e6}",
156
+ code="price_above_max",
157
+ body=preview,
158
+ )
159
+ order = self._json("POST", "/v1/orders", body)
160
+ if order["status"] in ("rejected", "failed"):
161
+ raise DelegateError(
162
+ f"Order {order['status']}", code=f"order_{order['status']}", body=order
163
+ )
164
+ result = order.get("result") or {}
165
+ return {
166
+ "id": result.get("conversation_id"),
167
+ "job_id": result.get("job_id"),
168
+ "order_id": order["order_id"],
169
+ "task_group_id": order.get("task_group_id"),
170
+ "status": order["status"],
171
+ "price_usd": total / 1e6,
172
+ }
173
+
174
+ def result(self, id: str, timeout: float = 0) -> dict[str, Any]:
175
+ """Job state and result. With a timeout (seconds), waits until it's no longer running."""
176
+ deadline = time.monotonic() + timeout
177
+ while True:
178
+ started = time.monotonic()
179
+ wait = min(30, max(0, int(deadline - started)))
180
+ detail = self._json(
181
+ "GET", f"/v1/conversations/{_q(id)}" + (f"?wait={wait}" if wait else "")
182
+ )
183
+ view = _job_view(id, detail)
184
+ if view["state"] != "running" or time.monotonic() >= deadline:
185
+ return view
186
+ elapsed = time.monotonic() - started
187
+ time.sleep(
188
+ max(0.0, min(MIN_POLL_SECONDS - elapsed, deadline - time.monotonic()))
189
+ )
190
+
191
+ def download(self, file: str) -> dict[str, Any]:
192
+ """Download a delivered file (a delegate-file:// URI or a file id): filename, content_type, data."""
193
+ file_id = file.removeprefix("delegate-file://")
194
+ headers, data = self._request(
195
+ "GET", f"/v1/files/{_q(file_id)}/content", accept="*/*"
196
+ )
197
+ return {
198
+ "filename": _filename_of(headers.get("Content-Disposition")) or file_id,
199
+ "content_type": headers.get("Content-Type") or "application/octet-stream",
200
+ "data": data,
201
+ }
202
+
203
+ def rate(
204
+ self,
205
+ id: str,
206
+ *,
207
+ quality: int,
208
+ value: int,
209
+ note: str | None = None,
210
+ model: str | None = None,
211
+ ) -> dict[str, Any]:
212
+ """Rate a finished job's quality and value from 1 to 5."""
213
+ conversation = self._json("GET", f"/v1/conversations/{_q(id)}")["conversation"]
214
+ out = self._mcp(
215
+ "rate_services",
216
+ {
217
+ "task_group_id": conversation.get("task_group_id"),
218
+ "reviewer_model_name": model or "dlgt-sdk",
219
+ "reviews": [
220
+ {
221
+ "service_id": conversation.get("service_id"),
222
+ "submission_id": str(uuid.uuid4()),
223
+ "rated_hire_count": 1,
224
+ "quality_rating": quality,
225
+ "value_rating": value,
226
+ "quality_note": note,
227
+ }
228
+ ],
229
+ },
230
+ )
231
+ review = ((out or {}).get("reviews") or [None])[0]
232
+ if not review or review.get("status") == "error":
233
+ error = (review or {}).get("error") or {}
234
+ raise DelegateError(
235
+ error.get("message", "Rating failed"),
236
+ code=error.get("code", "rating_failed"),
237
+ body=out,
238
+ )
239
+ return review
240
+
241
+ def balance(self) -> dict[str, Any]:
242
+ """Prepaid balance: balance_micros, funding_mode."""
243
+ return self._json("GET", "/v1/wallet/balance")
244
+
245
+ def _prepare(self, service, input, items, offer, scope, title, summary):
246
+ svc = self.service(service)
247
+ offer_version = _pick_offer(svc, offer, items)
248
+ fulfillment = offer_version["definition"]["fulfillment"]
249
+ task_input = dict(input or {})
250
+ # A batch-only service still takes a single input: one item.
251
+ if items is None and fulfillment.get("batch"):
252
+ items = [{"custom_id": "1", "input": task_input}]
253
+ bound_scope = _build_scope(
254
+ offer_version["definition"],
255
+ task_input,
256
+ len(items) if items else None,
257
+ scope,
258
+ )
259
+ task: dict[str, Any] = {
260
+ "title": (title or svc["title"])[:500],
261
+ "summary": (summary or json.dumps(items or task_input))[:5000],
262
+ "input": {} if items else task_input,
263
+ }
264
+ if items:
265
+ task["items"] = [
266
+ {"custom_id": item["custom_id"], "input": item["input"]}
267
+ for item in items
268
+ ]
269
+ preview = self._json(
270
+ "POST",
271
+ f"/v1/offers/{_q(offer_version['offer_version_id'])}/pricing-preview",
272
+ {"scope": bound_scope, "task": task},
273
+ )
274
+ return svc, offer_version, task, preview
275
+
276
+ def _mcp(self, name: str, arguments: dict[str, Any]) -> Any:
277
+ _, raw = self._request(
278
+ "POST",
279
+ "/mcp",
280
+ {
281
+ "jsonrpc": "2.0",
282
+ "id": 1,
283
+ "method": "tools/call",
284
+ "params": {"name": name, "arguments": arguments},
285
+ },
286
+ accept="application/json, text/event-stream",
287
+ )
288
+ message = json.loads(raw)
289
+ if message.get("error"):
290
+ raise DelegateError(
291
+ message["error"].get("message", "MCP error"),
292
+ code=str(message["error"].get("code")),
293
+ body=message,
294
+ )
295
+ result = message.get("result") or {}
296
+ payload = result.get("structuredContent")
297
+ text = next(
298
+ (
299
+ block.get("text")
300
+ for block in result.get("content") or []
301
+ if block.get("type") == "text"
302
+ ),
303
+ None,
304
+ )
305
+ if payload is None and isinstance(text, str):
306
+ try:
307
+ payload = json.loads(text)
308
+ except ValueError:
309
+ payload = text
310
+ if result.get("isError"):
311
+ detail = (
312
+ {"message": payload}
313
+ if isinstance(payload, str)
314
+ else (payload or {}).get("error") or payload or {}
315
+ )
316
+ raise DelegateError(
317
+ detail.get("message", "Tool call failed"),
318
+ code=detail.get("code", "tool_error"),
319
+ body=payload,
320
+ )
321
+ return payload
322
+
323
+ def _json(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
324
+ _, raw = self._request(method, path, body)
325
+ return json.loads(raw) if raw else None
326
+
327
+ def _request(
328
+ self, method, path, body=None, accept="application/json", retried=False
329
+ ):
330
+ headers = {
331
+ "Authorization": f"Bearer {self.api_key}",
332
+ "Accept": accept,
333
+ "User-Agent": f"dlgt-io-python/{__version__}",
334
+ }
335
+ data = None
336
+ if body is not None:
337
+ data = json.dumps(
338
+ {key: value for key, value in body.items() if value is not None}
339
+ ).encode()
340
+ headers["Content-Type"] = "application/json"
341
+ status, response_headers, raw = self._send(
342
+ method, self.base_url + path, headers, data
343
+ )
344
+ # Honour Retry-After once. Never hammer: repeated limiter hits get the IP banned.
345
+ retry_after = _float(response_headers.get("Retry-After"))
346
+ if status == 429 and not retried and retry_after and 0 < retry_after <= 30:
347
+ time.sleep(retry_after)
348
+ return self._request(method, path, body, accept, True)
349
+ if status >= 400:
350
+ raise _error_from(status, raw)
351
+ return response_headers, raw
352
+
353
+ def _send(self, method: str, url: str, headers: dict[str, str], data: bytes | None):
354
+ """The only network call; tests replace it."""
355
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
356
+ try:
357
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
358
+ return response.status, response.headers, response.read()
359
+ except urllib.error.HTTPError as error:
360
+ return error.code, error.headers, error.read()
361
+
362
+
363
+ def _pick_offer(
364
+ svc: dict[str, Any], offer: str | None, items: list | None
365
+ ) -> dict[str, Any]:
366
+ active = [o for o in svc.get("offer_versions") or [] if o.get("state") == "active"]
367
+ tasks = [o for o in active if o["definition"]["fulfillment"]["kind"] == "task"]
368
+ if offer:
369
+ candidates = [
370
+ o
371
+ for o in active
372
+ if offer in (o["offer_version_id"], o["definition"]["offer_key"])
373
+ ]
374
+ elif items is not None:
375
+ candidates = [o for o in tasks if o["definition"]["fulfillment"].get("batch")]
376
+ else:
377
+ candidates = [
378
+ o for o in tasks if not o["definition"]["fulfillment"].get("batch")
379
+ ] or tasks
380
+ if len(candidates) != 1:
381
+ keys = ", ".join(o["definition"]["offer_key"] for o in active)
382
+ message = (
383
+ f"Choose an offer with offer=: {keys}"
384
+ if keys
385
+ else "This service has no active offers"
386
+ )
387
+ raise DelegateError(message, code="offer_selection")
388
+ if candidates[0]["definition"]["fulfillment"]["kind"] != "task":
389
+ raise DelegateError(
390
+ "Entitlement offers aren't supported yet", code="unsupported_offer"
391
+ )
392
+ return candidates[0]
393
+
394
+
395
+ def _build_scope(
396
+ definition: dict[str, Any], task_input: dict[str, Any], item_count, override
397
+ ) -> dict[str, Any]:
398
+ """Scope keys bound to input keys must be equal; fill each side from the other. Mutates task_input."""
399
+ bindings = [
400
+ b
401
+ for b in definition["fulfillment"].get("bindings") or []
402
+ if b.get("task_input_key")
403
+ ]
404
+ scope = {
405
+ b["scope_key"]: task_input[b["task_input_key"]]
406
+ for b in bindings
407
+ if b["task_input_key"] in task_input
408
+ }
409
+ count_key = (definition["fulfillment"].get("batch") or {}).get("count_scope_key")
410
+ if count_key and item_count is not None:
411
+ scope[count_key] = item_count
412
+ scope.update(override or {})
413
+ for b in bindings:
414
+ if b["task_input_key"] not in task_input and b["scope_key"] in scope:
415
+ task_input[b["task_input_key"]] = scope[b["scope_key"]]
416
+ missing = [
417
+ key
418
+ for key in (definition.get("scope") or {}).get("required") or []
419
+ if key not in scope
420
+ ]
421
+ if missing:
422
+ raise DelegateError(
423
+ f"Missing scope: {', '.join(missing)}. Pass them in input or in scope=.",
424
+ code="missing_scope",
425
+ )
426
+ return scope
427
+
428
+
429
+ def _job_view(id: str, detail: dict[str, Any]) -> dict[str, Any]:
430
+ conversation = detail.get("conversation") or {}
431
+ jobs = detail.get("jobs") or []
432
+ job = jobs[-1] if jobs else {}
433
+ payload = (
434
+ job.get("result_payload")
435
+ if isinstance(job.get("result_payload"), dict)
436
+ else None
437
+ )
438
+ questions = [
439
+ r for r in detail.get("feedback_requests") or [] if r.get("status") == "pending"
440
+ ]
441
+ view = {
442
+ "id": id,
443
+ "state": "running",
444
+ "result": None,
445
+ "error": None,
446
+ "questions": questions,
447
+ "raw": detail,
448
+ }
449
+ if payload and (
450
+ job.get("status") == "delivered"
451
+ or conversation.get("status") in ("delivered", "completed")
452
+ ):
453
+ if payload.get("outcome") == "failed_provider":
454
+ reason = (
455
+ (payload.get("settlement") or {}).get("reason")
456
+ or payload.get("content")
457
+ or "The provider failed"
458
+ )
459
+ return {**view, "state": "failed", "error": reason}
460
+ return {**view, "state": "delivered", "result": payload}
461
+ if job.get("status") in ("rejected", "dispatch_failed"):
462
+ return {**view, "state": "failed", "error": f"Job {job['status']}"}
463
+ if conversation.get("status") == "disputed":
464
+ return {**view, "state": "failed", "error": "Disputed"}
465
+ if questions:
466
+ return {**view, "state": "needs_input"}
467
+ return view
468
+
469
+
470
+ def _error_from(status: int, raw: bytes) -> DelegateError:
471
+ text = raw.decode("utf-8", "replace")
472
+ try:
473
+ body: Any = json.loads(text)
474
+ except ValueError:
475
+ body = text
476
+ detail = body.get("detail", body) if isinstance(body, dict) else body
477
+ code = None
478
+ if isinstance(detail, str):
479
+ message = detail
480
+ elif isinstance(detail, list):
481
+ code = "validation_error"
482
+ message = "; ".join(
483
+ f"{'.'.join(map(str, d.get('loc') or []))}: {d.get('msg')}" for d in detail
484
+ )
485
+ else:
486
+ detail = detail or {}
487
+ code = detail.get("code") or detail.get("error_type")
488
+ message = detail.get("message") or text or "Request failed"
489
+ if code == "insufficient_escrow":
490
+ message += f". Top up at {WALLET_URL}"
491
+ return DelegateError(f"{status} {message}", status, code, body)
492
+
493
+
494
+ def _filename_of(disposition: str | None) -> str | None:
495
+ if not disposition:
496
+ return None
497
+ encoded = re.search(r"filename\*=UTF-8''([^;]+)", disposition, re.I)
498
+ if encoded:
499
+ return urllib.parse.unquote(encoded.group(1))
500
+ plain = re.search(r'filename="([^"]+)"', disposition, re.I)
501
+ return plain.group(1) if plain else None
502
+
503
+
504
+ def _micros(usd: float) -> int:
505
+ return round(usd * 1_000_000)
506
+
507
+
508
+ def _float(value: str | None) -> float | None:
509
+ try:
510
+ return float(value) if value else None
511
+ except ValueError:
512
+ return None
513
+
514
+
515
+ def _q(value: str) -> str:
516
+ return urllib.parse.quote(value, safe="")
File without changes
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: dlgt-io
3
+ Version: 0.1.0
4
+ Summary: Hire AI services on Delegate: search, hire with a price cap, collect results, rate.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://dlgt.io
7
+ Project-URL: Source, https://github.com/Dlgt-io/dlgt
8
+ Project-URL: Sign up, https://app.dlgt.io/signup
9
+ Keywords: delegate,dlgt,ai-agents,agent-marketplace,mcp,sdk
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ <p align="center">
22
+ <a href="https://dlgt.io"><img src="https://app.dlgt.io/brand/readme-banner@2x.png" alt="Delegate: give your agent superpowers" width="100%"></a>
23
+ </p>
24
+
25
+ # dlgt-io
26
+
27
+ Hire AI services on [Delegate](https://dlgt.io) from Python: search the marketplace, hire a service with a price cap, collect the result, rate it. Standard library only; Python 3.10+.
28
+
29
+ ```bash
30
+ pip install dlgt-io
31
+ ```
32
+
33
+ **New to Delegate?** [Sign up](https://app.dlgt.io/signup), create an API key at [app.dlgt.io/keys](https://app.dlgt.io/keys), and add funds at [app.dlgt.io/wallet](https://app.dlgt.io/wallet). The service in the example below is free.
34
+
35
+ ## How it works
36
+
37
+ ![Search, hire with a price cap, collect the result, rate it; the prepaid balance pays and refunds failed jobs](https://cdn.jsdelivr.net/gh/Dlgt-io/dlgt@v0.1.0/assets/how-it-works.svg)
38
+
39
+ ## Quickstart
40
+
41
+ ```bash
42
+ export DELEGATE_API_KEY=dlg_...
43
+ ```
44
+
45
+ ```python
46
+ import os
47
+ from dlgt_io import Delegate
48
+
49
+ # Pass your key, or leave it out to read DELEGATE_API_KEY
50
+ dlgt = Delegate(api_key=os.environ["DELEGATE_API_KEY"])
51
+
52
+ # 1. Find a service
53
+ found = dlgt.search("weather forecast for a US location")
54
+ for service in found["results"]:
55
+ print(service["title"], service["price_estimate"]["price_display"])
56
+
57
+ # 2. Hire it. Nothing is ordered if the price is above max_price_usd.
58
+ job = dlgt.hire(
59
+ "noaa-us-point-forecast",
60
+ input={"latitude": 38.8977, "longitude": -77.0365},
61
+ max_price_usd=0.10,
62
+ )
63
+
64
+ # 3. Collect the result, waiting up to 5 minutes
65
+ done = dlgt.result(job["id"], timeout=300)
66
+ print(done["state"], done["result"]["content"])
67
+
68
+ # 4. Rate it
69
+ dlgt.rate(job["id"], quality=5, value=4)
70
+ ```
71
+
72
+ `hire` and `quote` accept a service id or its key (here `noaa-us-point-forecast`). `dlgt.service(id_or_key)` shows a service's input schema and offers.
73
+
74
+ ## What a job goes through
75
+
76
+ ![Ordered, then running, then delivered, needs input, or failed with a refund](https://cdn.jsdelivr.net/gh/Dlgt-io/dlgt@v0.1.0/assets/job-states.svg)
77
+
78
+ `result()` returns a dict whose `state` is `running`, `needs_input` (the provider asked a question; answer it at `app.dlgt.io/activity/<id>`), `delivered` (with `result`), or `failed` (with `error`).
79
+
80
+ ## API
81
+
82
+ | Method | What it does |
83
+ |---|---|
84
+ | `search(query, limit=None, budget_usd=None, tags=None)` | Find services, best match first. `budget_usd` ranks cheaper services higher; it doesn't filter. |
85
+ | `service(id_or_key)` | Input schema, offers, price and ratings |
86
+ | `quote(service, input=...)` | Exact price for this input. Nothing is reserved or charged. |
87
+ | `hire(service, input=..., max_price_usd=...)` | Quote and order. Raises `price_above_max` above the cap. |
88
+ | `result(id, timeout=0)` | State and result; with `timeout` (seconds) it waits until the job is done |
89
+ | `download(file)` | `filename`, `content_type` and `data` of a `delegate-file://` output |
90
+ | `rate(id, quality=..., value=..., note=None)` | Rate quality and value from 1 to 5 |
91
+ | `balance()` | Your prepaid balance (`balance_micros`, 1 USD = 1,000,000) |
92
+
93
+ `hire` and `quote` also take `offer` (when a service has several), `scope`, and `items` for batch offers. `hire` takes an `idempotency_key` if you retry orders yourself. The client is synchronous; in async code, call it with `asyncio.to_thread`.
94
+
95
+ ## Errors
96
+
97
+ Every failure raises `DelegateError` with `status`, `code` and a message that says what to do:
98
+
99
+ | `code` | Meaning |
100
+ |---|---|
101
+ | `price_above_max` | The quote is above `max_price_usd`; nothing was ordered |
102
+ | `insufficient_escrow` (402) | Your balance is too low; top up at app.dlgt.io/wallet |
103
+ | `api_key_budget_exceeded` (403) | The key's spending limit is reached |
104
+ | `missing_scope` | The service needs more input; the message names the fields |
105
+ | `offer_selection` | The service has several offers; pass `offer` |
106
+ | `missing_api_key` | Set `DELEGATE_API_KEY` or pass `api_key` |
107
+
108
+ On a 429 the client waits for `Retry-After` once.
109
+
110
+ ## Configuration
111
+
112
+ | Variable | Default |
113
+ |---|---|
114
+ | `DELEGATE_API_KEY` | none (required) |
115
+ | `DELEGATE_API_BASE_URL` | `https://app.dlgt.io/api` |
116
+
117
+ Or pass them directly: `Delegate(api_key=..., base_url=...)`.
118
+
119
+ ## More
120
+
121
+ - CLI: [`@dlgt-io/cli`](https://www.npmjs.com/package/@dlgt-io/cli) on npm
122
+ - TypeScript SDK: [`@dlgt-io/sdk`](https://www.npmjs.com/package/@dlgt-io/sdk)
123
+ - MCP server: [dlgt.io/docs](https://dlgt.io/docs)
124
+ - Source and issues: [github.com/Dlgt-io/dlgt](https://github.com/Dlgt-io/dlgt)
125
+
126
+ MIT license.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/dlgt_io/__init__.py
5
+ src/dlgt_io/py.typed
6
+ src/dlgt_io.egg-info/PKG-INFO
7
+ src/dlgt_io.egg-info/SOURCES.txt
8
+ src/dlgt_io.egg-info/dependency_links.txt
9
+ src/dlgt_io.egg-info/top_level.txt
10
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ dlgt_io
@@ -0,0 +1,362 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import unittest
5
+ import urllib.parse
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
9
+
10
+ from dlgt_io import Delegate, DelegateError # noqa: E402
11
+
12
+ SERVICE_ID = "11111111-2222-3333-4444-555555555555"
13
+
14
+
15
+ def offer(key, batch=None, bindings=(), required=(), funding=None):
16
+ fulfillment = {"kind": "task", "bindings": list(bindings)}
17
+ if batch:
18
+ fulfillment["batch"] = {"count_scope_key": batch}
19
+ return {
20
+ "offer_version_id": f"ov-{key}",
21
+ "state": "active",
22
+ "definition": {
23
+ "offer_key": key,
24
+ "scope": {"required": list(required)},
25
+ "funding": funding or {"kind": "exact"},
26
+ "fulfillment": fulfillment,
27
+ },
28
+ }
29
+
30
+
31
+ SERVICE = {
32
+ "id": SERVICE_ID,
33
+ "title": "Company signals",
34
+ "filesystem_runtime_modes": ["live_workspace_v1"],
35
+ "offer_versions": [
36
+ offer(
37
+ "one",
38
+ bindings=[
39
+ {"scope_key": "section_count", "task_input_key": "section_count"}
40
+ ],
41
+ required=["section_count"],
42
+ ),
43
+ offer("batch", batch="company_count", required=["company_count"]),
44
+ ],
45
+ }
46
+
47
+ QUOTE = {
48
+ "funding": {"kind": "exact"},
49
+ "canonical_scope": {"section_count": 2},
50
+ "resolution": {"total_micros": 300000},
51
+ "pricing_resolution_id": "pr-1",
52
+ "expires_at": "2026-10-05T12:00:00Z",
53
+ }
54
+
55
+ ORDER = {
56
+ "order_id": "o-1",
57
+ "task_group_id": "tg-1",
58
+ "status": "contracted",
59
+ "result": {"job_id": "j-1", "conversation_id": "c-1"},
60
+ "funding_amount_micros": 300000,
61
+ }
62
+
63
+
64
+ def reply(payload, status=200, headers=None):
65
+ return status, headers or {}, json.dumps(payload).encode()
66
+
67
+
68
+ class FakeApi(Delegate):
69
+ """Routes "METHOD /path" to a payload, or to fn(body) -> (status, headers, bytes); records calls."""
70
+
71
+ def __init__(self, routes):
72
+ super().__init__(api_key="dlg_test", base_url="https://x.test/api")
73
+ self.routes = routes
74
+ self.calls = []
75
+
76
+ def _send(self, method, url, headers, data):
77
+ key = f"{method} {urllib.parse.urlsplit(url).path.removeprefix('/api')}"
78
+ body = json.loads(data) if data else None
79
+ self.calls.append({"key": key, "body": body, "headers": headers})
80
+ route = self.routes.get(key)
81
+ if route is None:
82
+ return reply({"detail": f"no route {key}"}, 404)
83
+ return route(body) if callable(route) else reply(route)
84
+
85
+ def sent(self, key):
86
+ return next((call["body"] for call in self.calls if call["key"] == key), None)
87
+
88
+
89
+ class HireTest(unittest.TestCase):
90
+ def test_picks_the_single_offer_binds_scope_and_orders_the_signed_quote(self):
91
+ api = FakeApi(
92
+ {
93
+ f"GET /v1/services/{SERVICE_ID}": SERVICE,
94
+ "POST /v1/offers/ov-one/pricing-preview": QUOTE,
95
+ "POST /v1/orders": ORDER,
96
+ }
97
+ )
98
+ hire = api.hire(
99
+ SERVICE_ID,
100
+ input={"domain": "example.com", "section_count": 2},
101
+ max_price_usd=0.5,
102
+ )
103
+
104
+ self.assertEqual(
105
+ hire,
106
+ {
107
+ "id": "c-1",
108
+ "job_id": "j-1",
109
+ "order_id": "o-1",
110
+ "task_group_id": "tg-1",
111
+ "status": "contracted",
112
+ "price_usd": 0.3,
113
+ },
114
+ )
115
+ preview = api.sent("POST /v1/offers/ov-one/pricing-preview")
116
+ self.assertEqual(preview["scope"], {"section_count": 2})
117
+ self.assertEqual(
118
+ preview["task"]["input"], {"domain": "example.com", "section_count": 2}
119
+ )
120
+ order = api.sent("POST /v1/orders")
121
+ self.assertEqual(order["schema_version"], 2)
122
+ self.assertEqual(order["pricing_resolution_id"], "pr-1")
123
+ self.assertEqual(order["scope"], QUOTE["canonical_scope"])
124
+ self.assertEqual(order["task"], preview["task"])
125
+ self.assertNotIn("buyer_cap_micros", order)
126
+ self.assertNotIn("idempotency_key", order)
127
+ self.assertEqual(order["execution_context"], {})
128
+
129
+ def test_refuses_a_quote_above_the_cap_and_orders_nothing(self):
130
+ api = FakeApi(
131
+ {
132
+ f"GET /v1/services/{SERVICE_ID}": SERVICE,
133
+ "POST /v1/offers/ov-one/pricing-preview": QUOTE,
134
+ "POST /v1/orders": ORDER,
135
+ }
136
+ )
137
+ with self.assertRaises(DelegateError) as caught:
138
+ api.hire(SERVICE_ID, input={"section_count": 2}, max_price_usd=0.1)
139
+ self.assertEqual(caught.exception.code, "price_above_max")
140
+ self.assertIsNone(api.sent("POST /v1/orders"))
141
+
142
+ def test_batch_only_buyer_cap_service_wraps_input_and_caps_at_the_offer_maximum(
143
+ self,
144
+ ):
145
+ funding = {
146
+ "kind": "buyer_cap",
147
+ "minimum_cap_micros": 100000,
148
+ "maximum_cap_micros": 2000000,
149
+ }
150
+ capped = {
151
+ **SERVICE,
152
+ "filesystem_runtime_modes": ["snapshot_v1"],
153
+ "file_contract_hash": "fch-1",
154
+ "offer_versions": [
155
+ offer(
156
+ "batch",
157
+ batch="company_count",
158
+ required=["company_count"],
159
+ funding=funding,
160
+ )
161
+ ],
162
+ }
163
+ api = FakeApi(
164
+ {
165
+ "GET /v1/services/by-key/company-signals": capped,
166
+ "POST /v1/offers/ov-batch/pricing-preview": {
167
+ **QUOTE,
168
+ "funding": funding,
169
+ },
170
+ "POST /v1/orders": ORDER,
171
+ }
172
+ )
173
+ api.hire("company-signals", input={"domain": "example.com"}, max_price_usd=5)
174
+
175
+ preview = api.sent("POST /v1/offers/ov-batch/pricing-preview")
176
+ self.assertEqual(preview["scope"], {"company_count": 1})
177
+ self.assertEqual(
178
+ preview["task"]["items"],
179
+ [{"custom_id": "1", "input": {"domain": "example.com"}}],
180
+ )
181
+ order = api.sent("POST /v1/orders")
182
+ self.assertEqual(order["buyer_cap_micros"], 2000000)
183
+ self.assertEqual(order["execution_context"], {"file_contract_hash": "fch-1"})
184
+
185
+ def test_missing_scope_is_reported_before_quoting(self):
186
+ api = FakeApi({f"GET /v1/services/{SERVICE_ID}": SERVICE})
187
+ with self.assertRaises(DelegateError) as caught:
188
+ api.quote(SERVICE_ID, input={"domain": "example.com"})
189
+ self.assertEqual(caught.exception.code, "missing_scope")
190
+ self.assertIn("section_count", str(caught.exception))
191
+ self.assertEqual(len(api.calls), 1)
192
+
193
+
194
+ class ResultTest(unittest.TestCase):
195
+ def test_maps_conversation_detail_to_a_job_state(self):
196
+ cases = [
197
+ (
198
+ {
199
+ "conversation": {"status": "delivered"},
200
+ "jobs": [
201
+ {"status": "delivered", "result_payload": {"content": "ok"}}
202
+ ],
203
+ },
204
+ "delivered",
205
+ None,
206
+ ),
207
+ (
208
+ {
209
+ "conversation": {"status": "delivered"},
210
+ "jobs": [
211
+ {
212
+ "status": "delivered",
213
+ "result_payload": {
214
+ "outcome": "failed_provider",
215
+ "settlement": {"reason": "upstream down"},
216
+ },
217
+ }
218
+ ],
219
+ },
220
+ "failed",
221
+ "upstream down",
222
+ ),
223
+ (
224
+ {
225
+ "conversation": {"status": "proposed"},
226
+ "jobs": [{"status": "rejected"}],
227
+ },
228
+ "failed",
229
+ "Job rejected",
230
+ ),
231
+ (
232
+ {
233
+ "conversation": {"status": "in_progress"},
234
+ "jobs": [{"status": "in_progress"}],
235
+ "feedback_requests": [
236
+ {"status": "pending", "prompt": "Which year?"}
237
+ ],
238
+ },
239
+ "needs_input",
240
+ None,
241
+ ),
242
+ (
243
+ {
244
+ "conversation": {"status": "in_progress"},
245
+ "jobs": [{"status": "in_progress"}],
246
+ },
247
+ "running",
248
+ None,
249
+ ),
250
+ ]
251
+ for detail, state, error in cases:
252
+ view = FakeApi({"GET /v1/conversations/c-1": detail}).result("c-1")
253
+ self.assertEqual((view["state"], view["error"]), (state, error), detail)
254
+ delivered = FakeApi({"GET /v1/conversations/c-1": cases[0][0]}).result("c-1")
255
+ self.assertEqual(delivered["result"], {"content": "ok"})
256
+
257
+
258
+ class RateTest(unittest.TestCase):
259
+ def test_sends_rate_services_over_mcp_for_the_conversations_task_group(self):
260
+ mcp_reply = {
261
+ "jsonrpc": "2.0",
262
+ "id": 1,
263
+ "result": {
264
+ "content": [
265
+ {
266
+ "type": "text",
267
+ "text": json.dumps({"reviews": [{"status": "saved"}]}),
268
+ }
269
+ ]
270
+ },
271
+ }
272
+ api = FakeApi(
273
+ {
274
+ "GET /v1/conversations/c-1": {
275
+ "conversation": {"task_group_id": "tg-1", "service_id": SERVICE_ID}
276
+ },
277
+ "POST /mcp": lambda body: reply(mcp_reply),
278
+ }
279
+ )
280
+ review = api.rate("c-1", quality=5, value=4, note="fast")
281
+
282
+ self.assertEqual(review["status"], "saved")
283
+ call = next(c for c in api.calls if c["key"] == "POST /mcp")
284
+ self.assertIn("text/event-stream", call["headers"]["Accept"])
285
+ self.assertEqual(call["body"]["method"], "tools/call")
286
+ self.assertEqual(call["body"]["params"]["name"], "rate_services")
287
+ arguments = call["body"]["params"]["arguments"]
288
+ self.assertEqual(arguments["task_group_id"], "tg-1")
289
+ self.assertEqual(arguments["reviewer_model_name"], "dlgt-sdk")
290
+ entry = dict(arguments["reviews"][0])
291
+ self.assertTrue(entry.pop("submission_id"))
292
+ self.assertEqual(
293
+ entry,
294
+ {
295
+ "service_id": SERVICE_ID,
296
+ "rated_hire_count": 1,
297
+ "quality_rating": 5,
298
+ "value_rating": 4,
299
+ "quality_note": "fast",
300
+ },
301
+ )
302
+
303
+
304
+ class TransportTest(unittest.TestCase):
305
+ def test_download_returns_the_bytes_and_the_servers_filename(self):
306
+ headers = {
307
+ "Content-Type": "text/csv",
308
+ "Content-Disposition": "attachment; filename=\"r_port.csv\"; filename*=UTF-8''r%C3%A9port.csv",
309
+ }
310
+ api = FakeApi(
311
+ {"GET /v1/files/f-1/content": lambda body: (200, headers, b"\x01\x02\x03")}
312
+ )
313
+ file = api.download("delegate-file://f-1")
314
+
315
+ self.assertEqual(
316
+ file,
317
+ {
318
+ "filename": "réport.csv",
319
+ "content_type": "text/csv",
320
+ "data": b"\x01\x02\x03",
321
+ },
322
+ )
323
+ self.assertEqual(api.calls[0]["headers"]["Accept"], "*/*")
324
+
325
+ def test_api_errors_carry_status_code_and_where_to_fix_them(self):
326
+ api = FakeApi(
327
+ {
328
+ f"GET /v1/services/{SERVICE_ID}": SERVICE,
329
+ "POST /v1/offers/ov-one/pricing-preview": QUOTE,
330
+ "POST /v1/orders": lambda body: reply(
331
+ {
332
+ "detail": {
333
+ "error_type": "insufficient_escrow",
334
+ "message": "Insufficient escrow balance",
335
+ }
336
+ },
337
+ 402,
338
+ ),
339
+ }
340
+ )
341
+ with self.assertRaises(DelegateError) as caught:
342
+ api.hire(SERVICE_ID, input={"section_count": 2}, max_price_usd=1)
343
+ self.assertEqual(
344
+ (caught.exception.status, caught.exception.code),
345
+ (402, "insufficient_escrow"),
346
+ )
347
+ self.assertIn("app.dlgt.io/wallet", str(caught.exception))
348
+
349
+ def test_a_missing_api_key_fails_fast_and_says_where_to_get_one(self):
350
+ saved = os.environ.pop("DELEGATE_API_KEY", None)
351
+ try:
352
+ with self.assertRaises(DelegateError) as caught:
353
+ Delegate()
354
+ self.assertEqual(caught.exception.code, "missing_api_key")
355
+ self.assertIn("app.dlgt.io/keys", str(caught.exception))
356
+ finally:
357
+ if saved is not None:
358
+ os.environ["DELEGATE_API_KEY"] = saved
359
+
360
+
361
+ if __name__ == "__main__":
362
+ unittest.main()