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 +21 -0
- dlgt_io-0.1.0/PKG-INFO +126 -0
- dlgt_io-0.1.0/README.md +106 -0
- dlgt_io-0.1.0/pyproject.toml +33 -0
- dlgt_io-0.1.0/setup.cfg +4 -0
- dlgt_io-0.1.0/src/dlgt_io/__init__.py +516 -0
- dlgt_io-0.1.0/src/dlgt_io/py.typed +0 -0
- dlgt_io-0.1.0/src/dlgt_io.egg-info/PKG-INFO +126 -0
- dlgt_io-0.1.0/src/dlgt_io.egg-info/SOURCES.txt +10 -0
- dlgt_io-0.1.0/src/dlgt_io.egg-info/dependency_links.txt +1 -0
- dlgt_io-0.1.0/src/dlgt_io.egg-info/top_level.txt +1 -0
- dlgt_io-0.1.0/tests/test_client.py +362 -0
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
|
+

|
|
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
|
+

|
|
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.
|
dlgt_io-0.1.0/README.md
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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"]
|
dlgt_io-0.1.0/setup.cfg
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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()
|