usdf 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- usdf/__init__.py +102 -0
- usdf/_http.py +91 -0
- usdf/accept.py +187 -0
- usdf/client.py +842 -0
- usdf/errors.py +56 -0
- usdf/estimate.py +123 -0
- usdf/langchain.py +142 -0
- usdf/sse.py +49 -0
- usdf/types.py +407 -0
- usdf/x402.py +555 -0
- usdf-0.1.2.dist-info/METADATA +124 -0
- usdf-0.1.2.dist-info/RECORD +14 -0
- usdf-0.1.2.dist-info/WHEEL +4 -0
- usdf-0.1.2.dist-info/licenses/LICENSE +21 -0
usdf/client.py
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"""The client. Plain HTTP against the gateway's published endpoints; nothing here is computed that the
|
|
2
|
+
gateway does not report, except the client-side estimates in ``usdf.estimate``, labelled as such."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
from ._http import Transport
|
|
14
|
+
from .errors import USDFError
|
|
15
|
+
from .sse import read_sse
|
|
16
|
+
from .types import Account, ApiKey, Balance, ChatCompletion, CreatedKey, KeyLimits, Model, Pricing, Quote, Receipt, ReceiptLookup, X402Receipt
|
|
17
|
+
from . import x402 as _x402
|
|
18
|
+
from .x402 import CHAIN_ID, PaymentLimits, Signer, approve_permit2, check_accept, choose_accept, decode_payment_required, decode_payment_response, encode_payment_signature, known_asset, payment_limits, read_permit2_allowance, rpc_chain_id, sign_exact_payment, wallet_signer
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.usdf.fi"
|
|
21
|
+
SDK_VERSION = "0.1.2"
|
|
22
|
+
_UA = f"usdf-sdk-py/{SDK_VERSION}"
|
|
23
|
+
_REQUEST_ID = __import__("re").compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ChatCompletionStream:
|
|
27
|
+
"""A streamed completion: iterate the chunks (dicts in the OpenAI chunk shape); ``receipt`` is set
|
|
28
|
+
once the stream has been read to the end, from the gateway's final comment line. Stop early and it
|
|
29
|
+
stays None: the gateway bills what it produced and keeps the receipt under ``request_id``."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, response, x402: bool = False):
|
|
32
|
+
self._res = response
|
|
33
|
+
self.x402 = x402
|
|
34
|
+
self.request_id: Optional[str] = response.headers.get("x-request-id")
|
|
35
|
+
self.receipt: Optional[Union[Receipt, X402Receipt]] = None
|
|
36
|
+
self.payment_response: Optional[Dict[str, Any]] = None
|
|
37
|
+
pr = response.headers.get("payment-response")
|
|
38
|
+
if pr:
|
|
39
|
+
try:
|
|
40
|
+
self.payment_response = decode_payment_response(pr)
|
|
41
|
+
except USDFError:
|
|
42
|
+
self.payment_response = None
|
|
43
|
+
# Set when the provider's stream ended before it closed the answer (stream_interrupted): what
|
|
44
|
+
# arrived is billed and the receipt still follows, status partial.
|
|
45
|
+
self.interrupted: Optional[Dict[str, Any]] = None
|
|
46
|
+
self._done = False
|
|
47
|
+
|
|
48
|
+
def __iter__(self) -> Iterator[Dict[str, Any]]:
|
|
49
|
+
if self._done:
|
|
50
|
+
raise RuntimeError("this stream has already been read")
|
|
51
|
+
self._done = True
|
|
52
|
+
error = None
|
|
53
|
+
finished = False
|
|
54
|
+
try:
|
|
55
|
+
for kind, payload in read_sse(self._res.chunks):
|
|
56
|
+
if kind == "comment":
|
|
57
|
+
if payload.startswith("receipt "):
|
|
58
|
+
try:
|
|
59
|
+
d = json.loads(payload[8:])
|
|
60
|
+
self.receipt = X402Receipt.of(d) if self.x402 else Receipt.of(d)
|
|
61
|
+
except ValueError:
|
|
62
|
+
pass
|
|
63
|
+
continue
|
|
64
|
+
if payload == "[DONE]":
|
|
65
|
+
finished = True
|
|
66
|
+
break
|
|
67
|
+
try:
|
|
68
|
+
obj = json.loads(payload)
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
if isinstance(obj, dict) and isinstance(obj.get("error"), dict):
|
|
72
|
+
error = obj["error"]
|
|
73
|
+
if error.get("code") == "stream_interrupted":
|
|
74
|
+
self.interrupted = error
|
|
75
|
+
continue
|
|
76
|
+
yield obj
|
|
77
|
+
finished = True
|
|
78
|
+
finally:
|
|
79
|
+
try:
|
|
80
|
+
self._res.close()
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
# An error with no receipt: nothing was produced and nothing charged (upstream_unavailable).
|
|
84
|
+
if error and self.receipt is None:
|
|
85
|
+
raise USDFError(502, str(error.get("message", "stream error")), str(error.get("type", "upstream_error")), error.get("code"), request_id=self.request_id)
|
|
86
|
+
|
|
87
|
+
def text(self) -> str:
|
|
88
|
+
"""Reads the whole stream and returns the assistant text (content deltas joined)."""
|
|
89
|
+
out: List[str] = []
|
|
90
|
+
for chunk in self:
|
|
91
|
+
for c in chunk.get("choices") or []:
|
|
92
|
+
d = c.get("delta") or {}
|
|
93
|
+
if isinstance(d.get("content"), str):
|
|
94
|
+
out.append(d["content"])
|
|
95
|
+
return "".join(out)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class RunResult:
|
|
100
|
+
"""What ``USDF.run`` returns: the assistant's ``text`` and the gateway's ``receipt`` (as it returns
|
|
101
|
+
it: what was charged, or over x402 what was paid), the request ``id``, the whole completion when
|
|
102
|
+
the answer was not streamed, and over x402 the decoded PAYMENT-RESPONSE (``payment``)."""
|
|
103
|
+
|
|
104
|
+
text: str
|
|
105
|
+
receipt: Any # Receipt, or X402Receipt over x402
|
|
106
|
+
id: str
|
|
107
|
+
completion: Optional[ChatCompletion] = None
|
|
108
|
+
payment: Optional[Dict[str, Any]] = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _Chat:
|
|
112
|
+
def __init__(self, client: "USDF"):
|
|
113
|
+
self.completions = _Completions(client)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _Completions:
|
|
117
|
+
def __init__(self, client: "USDF"):
|
|
118
|
+
self._c = client
|
|
119
|
+
|
|
120
|
+
def create(self, model: str, messages: Sequence[Mapping[str, Any]], *, stream: bool = False, timeout: Optional[float] = None, **params: Any) -> Union[ChatCompletion, ChatCompletionStream]:
|
|
121
|
+
"""POST /v1/chat/completions. ``params`` are forwarded as sent (max_tokens, temperature, tools,
|
|
122
|
+
provider, stream_options, ...). With ``stream=True`` returns a ChatCompletionStream."""
|
|
123
|
+
body = {"model": model, "messages": list(messages), **params, "stream": stream}
|
|
124
|
+
if stream:
|
|
125
|
+
res = self._c._stream("POST", "/v1/chat/completions", body, auth="key", timeout=timeout)
|
|
126
|
+
return ChatCompletionStream(res)
|
|
127
|
+
out = self._c._json("POST", "/v1/chat/completions", body, auth="key", timeout=timeout)
|
|
128
|
+
if isinstance(out, dict) and out.get("object") == "batch.job":
|
|
129
|
+
raise USDFError(400, "this request was submitted as a batch job (a :batch model or batch=True); use batches.create to submit one and batches.wait to read it", "invalid_request_error", "batch_submitted", body=out)
|
|
130
|
+
return ChatCompletion.of(out)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _Receipts:
|
|
134
|
+
def __init__(self, client: "USDF"):
|
|
135
|
+
self._c = client
|
|
136
|
+
|
|
137
|
+
def get(self, request_id: str) -> ReceiptLookup:
|
|
138
|
+
"""GET /v1/receipts/{id}, with the key that made the request."""
|
|
139
|
+
if not _REQUEST_ID.match(request_id or ""):
|
|
140
|
+
raise USDFError(400, "not a request id", "invalid_request_error")
|
|
141
|
+
return ReceiptLookup.of(self._c._json("GET", f"/v1/receipts/{request_id}", auth="key"))
|
|
142
|
+
|
|
143
|
+
def list(self, **filters: Any) -> Dict[str, Any]:
|
|
144
|
+
"""GET /v1/receipts: the latest receipts, newest first. With a key, that key's own requests; with
|
|
145
|
+
only a session, the account's. Filters: limit, before, model, status, from_, to, min_cost_units, key."""
|
|
146
|
+
return self._c._json("GET", "/v1/receipts" + _qs(filters), auth="any")
|
|
147
|
+
|
|
148
|
+
def attestation(self, request_id: str) -> Dict[str, Any]:
|
|
149
|
+
"""GET /v1/privacy/receipts/{id}: the attested provider's signed receipt for a request served on
|
|
150
|
+
attested hardware (status pending, verified or failed)."""
|
|
151
|
+
if not _REQUEST_ID.match(request_id or ""):
|
|
152
|
+
raise USDFError(400, "not a request id", "invalid_request_error")
|
|
153
|
+
return self._c._json("GET", f"/v1/privacy/receipts/{request_id}", auth="key" if self._c.api_key else "none")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Models:
|
|
157
|
+
def __init__(self, client: "USDF"):
|
|
158
|
+
self._c = client
|
|
159
|
+
|
|
160
|
+
def list(self) -> List[Model]:
|
|
161
|
+
"""GET /v1/models: what a key can call right now."""
|
|
162
|
+
return [Model.of(m) for m in self._c._json("GET", "/v1/models", auth="none").get("data") or []]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _Mcp:
|
|
166
|
+
def __init__(self, client: "USDF"):
|
|
167
|
+
self._c = client
|
|
168
|
+
|
|
169
|
+
def call(self, name: str, arguments: Optional[Dict[str, Any]] = None, auth: str = "any") -> Dict[str, Any]:
|
|
170
|
+
"""One of the gateway's MCP tools over Streamable HTTP; returns its structured result."""
|
|
171
|
+
c = self._c
|
|
172
|
+
headers = c._headers(auth)
|
|
173
|
+
headers["content-type"] = "application/json"
|
|
174
|
+
headers["accept"] = "application/json, text/event-stream"
|
|
175
|
+
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments or {}}}).encode("utf-8")
|
|
176
|
+
res = c._transport.request("POST", f"{c.base_url}/mcp", headers, body, 120.0)
|
|
177
|
+
text = res.body.decode("utf-8", errors="replace")
|
|
178
|
+
msg = None
|
|
179
|
+
if "text/event-stream" in (res.headers.get("content-type") or ""):
|
|
180
|
+
for line in text.split("\n"):
|
|
181
|
+
if line.startswith("data: {"):
|
|
182
|
+
msg = json.loads(line[6:])
|
|
183
|
+
break
|
|
184
|
+
elif text:
|
|
185
|
+
msg = json.loads(text)
|
|
186
|
+
if res.status >= 400 or not msg:
|
|
187
|
+
raise USDFError(res.status, (msg or {}).get("error", {}).get("message", f"MCP answered {res.status}") if isinstance(msg, dict) else f"MCP answered {res.status}", "mcp_error", body=msg)
|
|
188
|
+
if msg.get("error"):
|
|
189
|
+
raise USDFError(400, str(msg["error"].get("message", "MCP error")), "mcp_error", str(msg["error"].get("code", "")), body=msg)
|
|
190
|
+
result = msg.get("result") or {}
|
|
191
|
+
if result.get("isError"):
|
|
192
|
+
env = None
|
|
193
|
+
try:
|
|
194
|
+
env = json.loads(((result.get("content") or [{}])[0] or {}).get("text") or "")
|
|
195
|
+
except ValueError:
|
|
196
|
+
env = None
|
|
197
|
+
if isinstance(env, dict) and isinstance(env.get("error"), dict):
|
|
198
|
+
raise USDFError(int(env.get("status") or 400), str(env["error"].get("message")), str(env["error"].get("type", "error")), env["error"].get("code"), body=env)
|
|
199
|
+
raise USDFError(400, str(((result.get("content") or [{}])[0] or {}).get("text") or "tool error"), "mcp_error", body=result)
|
|
200
|
+
return result.get("structuredContent") or {}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class _Auth:
|
|
204
|
+
"""Sign-in happens on the site (usdf.fi): pass its session token as ``session_token``. The gateway never
|
|
205
|
+
sees a password; email and password sign-in is retired."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, client: "USDF"):
|
|
208
|
+
self._c = client
|
|
209
|
+
|
|
210
|
+
def info(self) -> Dict[str, Any]:
|
|
211
|
+
"""GET /v1/auth: which sign-in the site uses, how a session token is checked, and every account path."""
|
|
212
|
+
return self._c._json("GET", "/v1/auth", auth="none")
|
|
213
|
+
|
|
214
|
+
def sign_out(self) -> None:
|
|
215
|
+
"""Ends this session at the gateway (DELETE /v1/session): it is refused from then on. The client
|
|
216
|
+
drops it either way."""
|
|
217
|
+
try:
|
|
218
|
+
self._c._request("DELETE", "/v1/session", auth="session")
|
|
219
|
+
finally:
|
|
220
|
+
self._c.session_token = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _Keys:
|
|
224
|
+
"""The account's keys on the gateway's own account endpoints, with the session (/v1/account/keys);
|
|
225
|
+
caps, the allowlist and the pause on /v1/keys/{id}."""
|
|
226
|
+
|
|
227
|
+
def __init__(self, client: "USDF"):
|
|
228
|
+
self._c = client
|
|
229
|
+
|
|
230
|
+
def create(self, label: Optional[str] = None, budget_units: Optional[int] = None, rate_limit_rpm: int = 60) -> CreatedKey:
|
|
231
|
+
"""POST /v1/account/keys: issues a key on the signed-in account; the key text is returned once.
|
|
232
|
+
``budget_units`` is a lifetime spend limit in whole USDF base units (1 unit = $0.000001)."""
|
|
233
|
+
if budget_units is not None and budget_units < 0:
|
|
234
|
+
raise USDFError(400, "budget_units must be zero or more", "invalid_request_error")
|
|
235
|
+
body: Dict[str, Any] = {"rate_limit_rpm": rate_limit_rpm}
|
|
236
|
+
if label is not None:
|
|
237
|
+
body["label"] = label
|
|
238
|
+
if budget_units is not None:
|
|
239
|
+
body["budget_units"] = str(int(budget_units))
|
|
240
|
+
row = self._c._json("POST", "/v1/account/keys", body, auth="session")
|
|
241
|
+
if not isinstance(row, dict) or not row.get("api_key"):
|
|
242
|
+
raise USDFError(502, "the gateway returned no key; list the keys to see whether it was made", "server_error", body=row)
|
|
243
|
+
return CreatedKey(str(row["key_id"]), str(row["api_key"]), str(row.get("prefix")))
|
|
244
|
+
|
|
245
|
+
def list(self, revoked: bool = False, limit: int = 100) -> List[ApiKey]:
|
|
246
|
+
"""GET /v1/account/keys: the account's active keys, or with ``revoked=True`` the revoked ones."""
|
|
247
|
+
q = _qs({"state": "revoked" if revoked else "active", "limit": min(max(limit, 1), 1000)})
|
|
248
|
+
page = self._c._json("GET", "/v1/account/keys" + q, auth="session")
|
|
249
|
+
return [ApiKey.of(r) for r in (page or {}).get("data") or []]
|
|
250
|
+
|
|
251
|
+
def revoke(self, key_id: str) -> bool:
|
|
252
|
+
"""DELETE /v1/account/keys/{id}: True, or False when the account has no live key with that id."""
|
|
253
|
+
try:
|
|
254
|
+
r = self._c._json("DELETE", f"/v1/account/keys/{urllib.parse.quote(key_id, safe='')}", auth="session")
|
|
255
|
+
except USDFError as e:
|
|
256
|
+
if e.status == 404:
|
|
257
|
+
return False
|
|
258
|
+
raise
|
|
259
|
+
return isinstance(r, dict) and r.get("revoked") is True
|
|
260
|
+
|
|
261
|
+
def rotate(self, key_id: str) -> Dict[str, Any]:
|
|
262
|
+
"""POST /v1/account/keys/{id}/rotate: a new key with the old one's label, scope and every control;
|
|
263
|
+
the old one is revoked in the same step. The new key is in the answer, once."""
|
|
264
|
+
return self._c._json("POST", f"/v1/account/keys/{urllib.parse.quote(key_id, safe='')}/rotate", {}, auth="session")
|
|
265
|
+
|
|
266
|
+
def limits(self, key_id: str) -> KeyLimits:
|
|
267
|
+
"""GET /v1/keys/{id}/limits: trailing-window spend, caps, pause state, privacy, webhook."""
|
|
268
|
+
return KeyLimits.of(self._c._json("GET", f"/v1/keys/{key_id}/limits", auth="session"))
|
|
269
|
+
|
|
270
|
+
def update(self, key_id: str, **patch: Any) -> KeyLimits:
|
|
271
|
+
"""PATCH /v1/keys/{id}: spend_limit_hour_units, spend_limit_day_units, spend_limit_month_units
|
|
272
|
+
(units, or None to clear), allowed_models (ids or prefixes ending in *; None allows every model),
|
|
273
|
+
paused, no_logs, private_tier, webhook_url. Unknown fields are refused by the gateway."""
|
|
274
|
+
body = {k: (str(v) if isinstance(v, int) and not isinstance(v, bool) and k.endswith("_units") else v) for k, v in patch.items()}
|
|
275
|
+
return KeyLimits.of(self._c._json("PATCH", f"/v1/keys/{key_id}", body, auth="session"))
|
|
276
|
+
|
|
277
|
+
def pause(self, key_id: str) -> KeyLimits:
|
|
278
|
+
return self.update(key_id, paused=True)
|
|
279
|
+
|
|
280
|
+
def resume(self, key_id: str) -> KeyLimits:
|
|
281
|
+
return self.update(key_id, paused=False)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class _X402Completions:
|
|
285
|
+
def __init__(self, x: "X402"):
|
|
286
|
+
self._x = x
|
|
287
|
+
|
|
288
|
+
def create(
|
|
289
|
+
self,
|
|
290
|
+
model: str,
|
|
291
|
+
messages: Sequence[Mapping[str, Any]],
|
|
292
|
+
*,
|
|
293
|
+
signer: Optional[Signer] = None,
|
|
294
|
+
wallet: Any = None,
|
|
295
|
+
asset: Optional[str] = None,
|
|
296
|
+
approve: bool = False,
|
|
297
|
+
rpc_url: Optional[str] = None,
|
|
298
|
+
max_amount: Union[int, str, None] = None,
|
|
299
|
+
pay_to: Union[str, Sequence[str], None] = None,
|
|
300
|
+
stream: bool = False,
|
|
301
|
+
timeout: Optional[float] = None,
|
|
302
|
+
**params: Any,
|
|
303
|
+
) -> Union[ChatCompletion, ChatCompletionStream]:
|
|
304
|
+
"""Pays for and runs one completion with no account. The payer is ``wallet`` (an eth-account
|
|
305
|
+
account or a private key) or ``signer``. ``asset`` is "USDF" (Permit2) or "USDG" (EIP-3009); by
|
|
306
|
+
default USDF when its Permit2 allowance covers the amount, else USDG. The allowance is read through
|
|
307
|
+
``rpc_url`` (default USDF_RPC_URL, else the chain's public RPC). ``approve=True`` sends the
|
|
308
|
+
one-time USDF approval instead. ``max_amount`` (base units; default USDF_MAX_AMOUNT, else 1 USDF)
|
|
309
|
+
and ``pay_to`` (default USDF_PAY_TO, else the payee the gateway names: ``X402.payees``) bound what is
|
|
310
|
+
signed: a 402 asking for more, for another payee, on another chain or in another token is refused."""
|
|
311
|
+
x = self._x
|
|
312
|
+
if signer is None:
|
|
313
|
+
if wallet is None:
|
|
314
|
+
raise USDFError(400, "x402 needs a payer: pass wallet= (an eth-account account) or signer=", "invalid_request_error")
|
|
315
|
+
signer = wallet_signer(wallet)
|
|
316
|
+
limits = payment_limits(max_amount, pay_to)
|
|
317
|
+
body = {"model": model, "messages": list(messages), **params, "stream": stream}
|
|
318
|
+
required = x.quote(model, messages, **params)["required"]
|
|
319
|
+
# The payee(s) resolved once for this 402, then held: the accept and the signature both answer to them.
|
|
320
|
+
limits = x.payees(required, limits)
|
|
321
|
+
accept = x.choose(required, signer, wallet=wallet, asset=asset, approve=approve, rpc_url=rpc_url, limits=limits)
|
|
322
|
+
payload = sign_exact_payment(required, accept, signer, limits=limits)
|
|
323
|
+
headers = {"payment-signature": encode_payment_signature(payload)}
|
|
324
|
+
if stream:
|
|
325
|
+
res = x._c._stream("POST", "/x402/v1/chat/completions", body, auth="none", timeout=timeout, extra_headers=headers)
|
|
326
|
+
return ChatCompletionStream(res, x402=True)
|
|
327
|
+
res = x._c._request("POST", "/x402/v1/chat/completions", body, auth="none", timeout=timeout, extra_headers=headers)
|
|
328
|
+
out = ChatCompletion.of(res.json() or {}, x402=True)
|
|
329
|
+
pr = res.headers.get("payment-response")
|
|
330
|
+
out.raw["payment_response"] = decode_payment_response(pr) if pr else None
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class X402:
|
|
335
|
+
"""Pay per request with no account. ``quote`` returns the 402 (amount, accepted assets); ``chat``
|
|
336
|
+
pays and runs. Any object with ``address`` and ``sign_typed_data`` is a signer."""
|
|
337
|
+
|
|
338
|
+
def __init__(self, client: "USDF"):
|
|
339
|
+
self._c = client
|
|
340
|
+
self.chat = _Chat.__new__(_Chat)
|
|
341
|
+
self.chat.completions = _X402Completions(self)
|
|
342
|
+
# eth_chainId per RPC URL, asked once.
|
|
343
|
+
self._chains: Dict[str, int] = {}
|
|
344
|
+
# The payee(s) the gateway's GET /v1/config published, and when they were read.
|
|
345
|
+
self._published: Optional[Any] = None
|
|
346
|
+
|
|
347
|
+
def quote(self, model: str, messages: Sequence[Mapping[str, Any]], **params: Any) -> Dict[str, Any]:
|
|
348
|
+
"""The 402 for this request: ``quote`` (amount, accepted), ``x402`` and the decoded ``required``."""
|
|
349
|
+
body = {"model": model, "messages": list(messages), **params, "stream": False}
|
|
350
|
+
headers = self._c._headers("none")
|
|
351
|
+
headers["content-type"] = "application/json"
|
|
352
|
+
res = self._c._transport.request("POST", f"{self._c.base_url}/x402/v1/chat/completions", headers, json.dumps(body).encode("utf-8"), self._c.timeout)
|
|
353
|
+
j = res.json()
|
|
354
|
+
if res.status != 402 or not isinstance(j, dict):
|
|
355
|
+
raise USDFError.from_response(res.status, j, res.headers)
|
|
356
|
+
header = res.headers.get("payment-required")
|
|
357
|
+
j["required"] = decode_payment_required(header) if header else j.get("x402")
|
|
358
|
+
return j
|
|
359
|
+
|
|
360
|
+
def read_rpc(self, rpc_url: Optional[str] = None) -> Optional[str]:
|
|
361
|
+
"""Where the allowance is read: ``rpc_url`` (default USDF_RPC_URL, else the chain's public RPC),
|
|
362
|
+
refused unless it answers for chain 4663. None when it cannot be reached (the allowance is then
|
|
363
|
+
unknown)."""
|
|
364
|
+
url = rpc_url or self._c.rpc_url or _x402.PUBLIC_RPC_URL
|
|
365
|
+
if url not in self._chains:
|
|
366
|
+
try:
|
|
367
|
+
self._chains[url] = rpc_chain_id(url)
|
|
368
|
+
except Exception:
|
|
369
|
+
return None
|
|
370
|
+
if self._chains[url] != CHAIN_ID:
|
|
371
|
+
raise USDFError(402, f"the RPC at {url} answers for chain {self._chains[url]}, not Robinhood Chain ({CHAIN_ID}); the allowance is read on {CHAIN_ID} only", "payment_refused", "wrong_chain")
|
|
372
|
+
return url
|
|
373
|
+
|
|
374
|
+
def gateway_payees(self) -> List[str]:
|
|
375
|
+
"""The payee(s) the gateway publishes at GATEWAY_CONFIG_URL (never the base URL's: a server there that
|
|
376
|
+
is not the gateway would vouch for itself), kept PAYEE_TTL_SECONDS; [] when it does not answer."""
|
|
377
|
+
now = time.monotonic()
|
|
378
|
+
if self._published is not None and now - self._published[0] <= _x402.PAYEE_TTL_SECONDS:
|
|
379
|
+
return list(self._published[1])
|
|
380
|
+
payees: List[str] = []
|
|
381
|
+
try:
|
|
382
|
+
res = self._c._transport.request("GET", _x402.GATEWAY_CONFIG_URL, {"accept": "application/json", "user-agent": _UA}, None, 10.0)
|
|
383
|
+
if 200 <= res.status < 300:
|
|
384
|
+
payees = _x402.published_payees(res.json())
|
|
385
|
+
except Exception:
|
|
386
|
+
payees = []
|
|
387
|
+
self._published = (now, payees) if payees else None
|
|
388
|
+
return payees
|
|
389
|
+
|
|
390
|
+
def payees(self, required: Dict[str, Any], limits: Optional[PaymentLimits] = None) -> PaymentLimits:
|
|
391
|
+
"""The limits a payment for this 402 is held to. The caller's payees when pinned (``pay_to=``,
|
|
392
|
+
USDF_PAY_TO). Otherwise the payee the gateway names: a 402 from the gateway itself (GATEWAY_ORIGIN)
|
|
393
|
+
names it; from any other base URL, a payee in the 402 is taken when GATEWAY_CONFIG_URL publishes it too,
|
|
394
|
+
or when it is GATEWAY_PAYEE (the last resort, so a 402 naming it reads nothing more). The hot wallet the
|
|
395
|
+
2026-10-26 rotation retired is paid only while the gateway names it: until the flip."""
|
|
396
|
+
limits = limits or payment_limits()
|
|
397
|
+
if limits.pinned:
|
|
398
|
+
return limits
|
|
399
|
+
named = _x402.named_payees(required)
|
|
400
|
+
if _x402.is_gateway_origin(self._c.base_url):
|
|
401
|
+
return _x402.with_gateway_payees(limits, named)
|
|
402
|
+
if all(a in limits.pay_to for a in named):
|
|
403
|
+
return limits
|
|
404
|
+
return _x402.with_gateway_payees(limits, self.gateway_payees())
|
|
405
|
+
|
|
406
|
+
def choose(self, required: Dict[str, Any], signer: Signer, *, wallet: Any = None, asset: Optional[str] = None, approve: bool = False, rpc_url: Optional[str] = None, limits: Optional[PaymentLimits] = None) -> Dict[str, Any]:
|
|
407
|
+
"""The accept this payer pays, after the allowance check (and the approval, when asked for). Every
|
|
408
|
+
accept is held to ``check_accept`` (under ``payees``) before anything is read, approved or signed."""
|
|
409
|
+
limits = self.payees(required, limits)
|
|
410
|
+
exact = [a for a in required.get("accepts") or [] if isinstance(a, dict) and a.get("scheme") == "exact"]
|
|
411
|
+
usdf = next((a for a in exact if known_asset(a) == "USDF"), None)
|
|
412
|
+
usdg = next((a for a in exact if known_asset(a) == "USDG"), None)
|
|
413
|
+
read: Dict[str, Optional[str]] = {}
|
|
414
|
+
|
|
415
|
+
def allowance_of(a: Dict[str, Any]) -> Optional[int]:
|
|
416
|
+
if "url" not in read:
|
|
417
|
+
read["url"] = self.read_rpc(rpc_url)
|
|
418
|
+
if read["url"] is None:
|
|
419
|
+
return None
|
|
420
|
+
try:
|
|
421
|
+
return read_permit2_allowance(read["url"], a["asset"], signer.address)
|
|
422
|
+
except Exception:
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
if asset:
|
|
426
|
+
accept = choose_accept(required, asset)
|
|
427
|
+
elif usdf is not None:
|
|
428
|
+
check_accept(usdf, limits)
|
|
429
|
+
allowance = allowance_of(usdf)
|
|
430
|
+
accept = usdf if allowance is None or allowance >= int(usdf["amount"]) or approve or usdg is None else usdg
|
|
431
|
+
else:
|
|
432
|
+
accept = choose_accept(required)
|
|
433
|
+
check_accept(accept, limits)
|
|
434
|
+
if known_asset(accept) == "USDF" and approve and wallet is not None:
|
|
435
|
+
allowance = allowance_of(accept)
|
|
436
|
+
if allowance is not None and allowance < int(accept["amount"]):
|
|
437
|
+
approve_permit2(wallet, read["url"], accept["asset"]) # type: ignore[arg-type]
|
|
438
|
+
return accept
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ── Batch tier, embeddings, account, links, zap, agents ──────────────────────
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _units(v: Any) -> Any:
|
|
445
|
+
return None if v is None else str(int(v))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _qs(params: Mapping[str, Any]) -> str:
|
|
449
|
+
out = {}
|
|
450
|
+
for k, v in params.items():
|
|
451
|
+
if v is None or v == "":
|
|
452
|
+
continue
|
|
453
|
+
k = k.rstrip("_") # from_ -> from
|
|
454
|
+
out[k] = str(int(v)) if k.endswith("_units") else ("true" if v is True else "false" if v is False else str(v))
|
|
455
|
+
return ("?" + urllib.parse.urlencode(out)) if out else ""
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _units_of(d: Mapping[str, Any]) -> Dict[str, Any]:
|
|
459
|
+
return {k: (_units(v) if k.endswith("_units") else v) for k, v in d.items()}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class _Batches:
|
|
463
|
+
"""The batch tier: a request run through the upstream's batch API at its batch rate, billed when the
|
|
464
|
+
result arrives (models whose price carries batch_input)."""
|
|
465
|
+
|
|
466
|
+
def __init__(self, client: "USDF"):
|
|
467
|
+
self._c = client
|
|
468
|
+
|
|
469
|
+
def create(self, model: str, messages: Sequence[Mapping[str, Any]], *, webhook_url: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
470
|
+
"""Submits the request as a job; returns the job (``id``, ``status``, ``hold``, ``price``)."""
|
|
471
|
+
body = {"model": model, "messages": list(messages), **params, "batch": True, "stream": False}
|
|
472
|
+
if webhook_url:
|
|
473
|
+
body["webhook_url"] = webhook_url
|
|
474
|
+
return self._c._json("POST", "/v1/chat/completions", body, auth="key")
|
|
475
|
+
|
|
476
|
+
def get(self, job_id: str) -> Dict[str, Any]:
|
|
477
|
+
return self._c._json("GET", f"/v1/batches/{urllib.parse.quote(job_id, safe='')}", auth="key")
|
|
478
|
+
|
|
479
|
+
def result(self, job_id: str) -> ChatCompletion:
|
|
480
|
+
return ChatCompletion.of(self._c._json("GET", f"/v1/batches/{urllib.parse.quote(job_id, safe='')}/result", auth="key"))
|
|
481
|
+
|
|
482
|
+
def wait(self, job_id: str, *, interval: float = 5.0, timeout: float = 25 * 3600.0) -> ChatCompletion:
|
|
483
|
+
"""Polls until the job ends and returns its result; a failed or expired job raises."""
|
|
484
|
+
deadline = time.time() + timeout
|
|
485
|
+
while True:
|
|
486
|
+
job = self.get(job_id)
|
|
487
|
+
status = job.get("status")
|
|
488
|
+
if status == "done":
|
|
489
|
+
return self.result(job_id)
|
|
490
|
+
if status in ("failed", "expired"):
|
|
491
|
+
raise USDFError(409, f"batch job {job_id} {status}{': ' + str(job.get('error')) if job.get('error') else ''}; nothing was charged", "invalid_request_error", f"batch_{status}", body=job)
|
|
492
|
+
if time.time() > deadline:
|
|
493
|
+
raise USDFError(408, f"batch job {job_id} is still {status}", "timeout", "batch_pending", body=job)
|
|
494
|
+
time.sleep(interval)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class _Embeddings:
|
|
498
|
+
def __init__(self, client: "USDF"):
|
|
499
|
+
self._c = client
|
|
500
|
+
|
|
501
|
+
def create(self, model: str, input: Union[str, Sequence[str]], **params: Any) -> Dict[str, Any]:
|
|
502
|
+
"""POST /v1/embeddings: the OpenAI embeddings list with the gateway's receipt."""
|
|
503
|
+
return self._c._json("POST", "/v1/embeddings", {"model": model, "input": input if isinstance(input, str) else list(input), **params}, auth="key")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class _Account:
|
|
507
|
+
"""The signed-in owner's reads and settings (a session)."""
|
|
508
|
+
|
|
509
|
+
def __init__(self, client: "USDF"):
|
|
510
|
+
self._c = client
|
|
511
|
+
|
|
512
|
+
def get(self) -> Account:
|
|
513
|
+
"""GET /v1/account: balance, held, linked wallet, deposit address."""
|
|
514
|
+
return Account.of(self._c._json("GET", "/v1/account", auth="session"))
|
|
515
|
+
|
|
516
|
+
def overview(self) -> Dict[str, Any]:
|
|
517
|
+
"""GET /v1/account/overview: balance, spend today / 7 / 30 days, top models, last deposit and withdrawal, receipt counts."""
|
|
518
|
+
return self._c._json("GET", "/v1/account/overview", auth="session")
|
|
519
|
+
|
|
520
|
+
def activity(self, **filters: Any) -> Dict[str, Any]:
|
|
521
|
+
"""GET /v1/account/activity: the account's requests, newest first. Filters: key, model, status,
|
|
522
|
+
from_, to, min_cost_units, before, limit."""
|
|
523
|
+
return self._c._json("GET", "/v1/account/activity" + _qs(filters), auth="session")
|
|
524
|
+
|
|
525
|
+
def activity_csv(self, **filters: Any) -> str:
|
|
526
|
+
return self._c._request("GET", "/v1/account/activity.csv" + _qs(filters), auth="session").body.decode("utf-8")
|
|
527
|
+
|
|
528
|
+
def settings(self) -> Dict[str, Any]:
|
|
529
|
+
return self._c._json("GET", "/v1/account/settings", auth="session")
|
|
530
|
+
|
|
531
|
+
def update_settings(self, **patch: Any) -> Dict[str, Any]:
|
|
532
|
+
"""PATCH /v1/account/settings: zdr, data_collection, no_logs_default, private_tier_default, sort,
|
|
533
|
+
provider_allow, provider_deny, allow_fallbacks, max_price, low_balance_units, low_balance_webhook_url, solana_address."""
|
|
534
|
+
return self._c._json("PATCH", "/v1/account/settings", _units_of(patch), auth="session")
|
|
535
|
+
|
|
536
|
+
def analytics(self, days: Optional[int] = None, key: Optional[str] = None) -> Dict[str, Any]:
|
|
537
|
+
return self._c._json("GET", "/v1/analytics" + _qs({"days": days, "key": key}), auth="any")
|
|
538
|
+
|
|
539
|
+
def withdraw(self, units: int, chain_id: Optional[int] = None) -> Dict[str, Any]:
|
|
540
|
+
"""POST /v1/account/withdrawals: sends available USDF to the linked wallet. ``units`` in USDF base
|
|
541
|
+
units, at least 100000 (0.10 USDF); ``chain_id`` for another chain the account is served on."""
|
|
542
|
+
body: Dict[str, Any] = {"units": str(int(units))}
|
|
543
|
+
if chain_id is not None:
|
|
544
|
+
body["chain_id"] = int(chain_id)
|
|
545
|
+
return self._c._json("POST", "/v1/account/withdrawals", body, auth="session")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class _Links:
|
|
549
|
+
"""USDF links: an amount set aside behind a claim token, claimed by the first signed-in account to present it."""
|
|
550
|
+
|
|
551
|
+
def __init__(self, client: "USDF"):
|
|
552
|
+
self._c = client
|
|
553
|
+
|
|
554
|
+
def create(self, units: int, memo: Optional[str] = None, expires_in_days: Optional[int] = None) -> Dict[str, Any]:
|
|
555
|
+
"""POST /v1/links (session): at least 10000 units (0.01 USDF); the token is in the answer, once."""
|
|
556
|
+
body: Dict[str, Any] = {"units": str(int(units))}
|
|
557
|
+
if memo is not None:
|
|
558
|
+
body["memo"] = memo
|
|
559
|
+
if expires_in_days is not None:
|
|
560
|
+
body["expires_in_days"] = expires_in_days
|
|
561
|
+
return self._c._json("POST", "/v1/links", body, auth="session")
|
|
562
|
+
|
|
563
|
+
def list(self) -> Dict[str, Any]:
|
|
564
|
+
return self._c._json("GET", "/v1/links", auth="any")
|
|
565
|
+
|
|
566
|
+
def get(self, token: str) -> Dict[str, Any]:
|
|
567
|
+
"""The public view of one link: amount, memo, status; never who sent or claimed it."""
|
|
568
|
+
return self._c._json("GET", f"/v1/links/{urllib.parse.quote(token, safe='')}", auth="none")
|
|
569
|
+
|
|
570
|
+
def claim(self, token: str) -> Dict[str, Any]:
|
|
571
|
+
return self._c._json("POST", f"/v1/links/{urllib.parse.quote(token, safe='')}/claim", {}, auth="session")
|
|
572
|
+
|
|
573
|
+
def cancel(self, link_id: str) -> Dict[str, Any]:
|
|
574
|
+
return self._c._json("POST", f"/v1/links/{urllib.parse.quote(link_id, safe='')}/cancel", {}, auth="session")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class _Zap:
|
|
578
|
+
def __init__(self, client: "USDF"):
|
|
579
|
+
self._c = client
|
|
580
|
+
|
|
581
|
+
def quote(self, token: str, amount: int, recipient: Optional[str] = None, slippage_bps: Optional[int] = None) -> Dict[str, Any]:
|
|
582
|
+
"""GET /v1/zap/quote: ETH ("eth") or an ERC-20 with a Uniswap pool against USDG, swapped and
|
|
583
|
+
wrapped into USDF for ``recipient``, with the transactions to send. No key."""
|
|
584
|
+
return self._c._json("GET", "/v1/zap/quote" + _qs({"token": token, "amount": str(int(amount)), "recipient": recipient, "slippage_bps": slippage_bps}), auth="none")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class _Pool:
|
|
588
|
+
def __init__(self, client: "USDF"):
|
|
589
|
+
self._c = client
|
|
590
|
+
|
|
591
|
+
def read(self) -> Dict[str, Any]:
|
|
592
|
+
"""GET /v1/pool: the USDF/USDG pool's price, tick, whether it sits in the seeded band, and its
|
|
593
|
+
liquidity and USDF/USDG reserves while the gateway publishes them (absent otherwise). Public, no key."""
|
|
594
|
+
return self._c._json("GET", "/v1/pool", auth="none")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class _Agents:
|
|
598
|
+
"""Agents with a ledger account of their own: created and funded by the owner, paying each other."""
|
|
599
|
+
|
|
600
|
+
def __init__(self, client: "USDF"):
|
|
601
|
+
self._c = client
|
|
602
|
+
|
|
603
|
+
def _read_auth(self) -> str:
|
|
604
|
+
return "any" if (self._c.session_token or self._c.api_key) else "none"
|
|
605
|
+
|
|
606
|
+
def create(self, slug: str, name: str, **policy: Any) -> Dict[str, Any]:
|
|
607
|
+
return self._c._json("POST", "/v1/agents", _units_of({"slug": slug, "name": name, **policy}), auth="session")
|
|
608
|
+
|
|
609
|
+
def list(self) -> Dict[str, Any]:
|
|
610
|
+
return self._c._json("GET", "/v1/agents?mine=1", auth="session")
|
|
611
|
+
|
|
612
|
+
def get(self, slug: str) -> Dict[str, Any]:
|
|
613
|
+
return self._c._json("GET", f"/v1/agents/{urllib.parse.quote(slug, safe='')}", auth=self._read_auth())
|
|
614
|
+
|
|
615
|
+
def update(self, slug: str, **policy: Any) -> Dict[str, Any]:
|
|
616
|
+
"""PATCH /v1/agents/{slug}: allowed_models, allowed_tools, max_request_units, spend_limit_*_units,
|
|
617
|
+
approval_threshold_units, webhook_url, public_page, identity_opt_in."""
|
|
618
|
+
return self._c._json("PATCH", f"/v1/agents/{urllib.parse.quote(slug, safe='')}", _units_of(policy), auth="session")
|
|
619
|
+
|
|
620
|
+
def fund(self, slug: str, units: int, direction: Optional[str] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
621
|
+
body: Dict[str, Any] = {"units": str(int(units))}
|
|
622
|
+
if direction:
|
|
623
|
+
body["direction"] = direction
|
|
624
|
+
return self._c._json("POST", f"/v1/agents/{urllib.parse.quote(slug, safe='')}/fund", body, auth="session", extra_headers={"idempotency-key": idempotency_key} if idempotency_key else None)
|
|
625
|
+
|
|
626
|
+
def pay(self, slug: str, to: str, units: int, memo: Optional[str] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
627
|
+
"""The agent pays another agent (by slug) or an account: with the agent's key, or the owner's session."""
|
|
628
|
+
body: Dict[str, Any] = {"to": to, "units": str(int(units))}
|
|
629
|
+
if memo is not None:
|
|
630
|
+
body["memo"] = memo
|
|
631
|
+
return self._c._json("POST", f"/v1/agents/{urllib.parse.quote(slug, safe='')}/pay", body, auth="any", extra_headers={"idempotency-key": idempotency_key} if idempotency_key else None)
|
|
632
|
+
|
|
633
|
+
def payments(self, slug: str) -> Dict[str, Any]:
|
|
634
|
+
return self._c._json("GET", f"/v1/agents/{urllib.parse.quote(slug, safe='')}/payments", auth=self._read_auth())
|
|
635
|
+
|
|
636
|
+
def rotate_key(self, slug: str) -> Dict[str, Any]:
|
|
637
|
+
return self._c._json("POST", f"/v1/agents/{urllib.parse.quote(slug, safe='')}/key", {}, auth="session")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _env(name: str) -> Optional[str]:
|
|
642
|
+
v = os.environ.get(name)
|
|
643
|
+
return v if v else None
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
class USDF:
|
|
647
|
+
"""``USDF(api_key=...)`` pays from a prepaid balance; ``USDF(wallet=...)`` pays each request over
|
|
648
|
+
x402 with no account (an eth-account account or a private key). ``session_token`` is the token of a
|
|
649
|
+
session signed in on the site (usdf.fi), for the account's own calls: keys, links, agents, the
|
|
650
|
+
dashboard. Everything a key can do needs only the key. ``base_url`` is the gateway
|
|
651
|
+
origin without /v1. Unset, ``api_key``, ``base_url``, ``session_token`` and ``rpc_url`` come from
|
|
652
|
+
USDF_API_KEY (not read when a wallet is given), USDF_BASE_URL, USDF_SESSION_TOKEN and USDF_RPC_URL
|
|
653
|
+
(else the chain's public RPC). Over x402, ``max_amount`` (base units; USDF_MAX_AMOUNT, else 1 USDF)
|
|
654
|
+
caps one payment and ``pay_to`` (USDF_PAY_TO, else the gateway's hot wallet) names the payee; a 402
|
|
655
|
+
asking for more, for another payee, on another chain or in another token is refused before signing."""
|
|
656
|
+
|
|
657
|
+
def __init__(
|
|
658
|
+
self,
|
|
659
|
+
api_key: Optional[str] = None,
|
|
660
|
+
*,
|
|
661
|
+
wallet: Any = None,
|
|
662
|
+
session_token: Optional[str] = None,
|
|
663
|
+
base_url: Optional[str] = None,
|
|
664
|
+
asset: Optional[str] = None,
|
|
665
|
+
approve: bool = False,
|
|
666
|
+
rpc_url: Optional[str] = None,
|
|
667
|
+
max_amount: Union[int, str, None] = None,
|
|
668
|
+
pay_to: Union[str, Sequence[str], None] = None,
|
|
669
|
+
timeout: float = 15 * 60.0,
|
|
670
|
+
transport: Optional[str] = None,
|
|
671
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
672
|
+
):
|
|
673
|
+
self.base_url = (base_url or _env("USDF_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
674
|
+
self.wallet = wallet
|
|
675
|
+
self.api_key = api_key if api_key is not None else (None if wallet is not None else _env("USDF_API_KEY"))
|
|
676
|
+
self.session_token = session_token if session_token is not None else _env("USDF_SESSION_TOKEN")
|
|
677
|
+
self.asset = asset
|
|
678
|
+
self.approve = approve
|
|
679
|
+
self.rpc_url = rpc_url or _env("USDF_RPC_URL")
|
|
680
|
+
# Checked (and USDF_MAX_AMOUNT / USDF_PAY_TO read) when a payment is made: x402.payment_limits.
|
|
681
|
+
self.max_amount = max_amount
|
|
682
|
+
self.pay_to = pay_to
|
|
683
|
+
self.timeout = timeout
|
|
684
|
+
self._extra = dict(headers or {})
|
|
685
|
+
self._transport = Transport(transport)
|
|
686
|
+
self.chat = _Chat(self)
|
|
687
|
+
self.receipts = _Receipts(self)
|
|
688
|
+
self.models = _Models(self)
|
|
689
|
+
self.mcp = _Mcp(self)
|
|
690
|
+
self.auth = _Auth(self)
|
|
691
|
+
self.keys = _Keys(self)
|
|
692
|
+
self.x402 = X402(self)
|
|
693
|
+
self.batches = _Batches(self)
|
|
694
|
+
self.embeddings = _Embeddings(self)
|
|
695
|
+
self.account = _Account(self)
|
|
696
|
+
self.links = _Links(self)
|
|
697
|
+
self.zap = _Zap(self)
|
|
698
|
+
self.pool = _Pool(self)
|
|
699
|
+
self.agents = _Agents(self)
|
|
700
|
+
|
|
701
|
+
@property
|
|
702
|
+
def transport(self) -> str:
|
|
703
|
+
return self._transport.name
|
|
704
|
+
|
|
705
|
+
@property
|
|
706
|
+
def pays_with(self) -> Optional[str]:
|
|
707
|
+
"""How a request is paid: "key" (prepaid), "wallet" (x402), or None."""
|
|
708
|
+
return "key" if self.api_key else "wallet" if self.wallet is not None else None
|
|
709
|
+
|
|
710
|
+
def _pay_kwargs(self) -> Dict[str, Any]:
|
|
711
|
+
"""What an x402 payment from this client's wallet is made with."""
|
|
712
|
+
return {"wallet": self.wallet, "asset": self.asset, "approve": self.approve, "rpc_url": self.rpc_url, "max_amount": self.max_amount, "pay_to": self.pay_to}
|
|
713
|
+
|
|
714
|
+
# ── The short path ────────────────────────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
def run(
|
|
717
|
+
self,
|
|
718
|
+
model: str,
|
|
719
|
+
prompt: Union[str, Sequence[Mapping[str, Any]]],
|
|
720
|
+
*,
|
|
721
|
+
max_tokens: Optional[int] = None,
|
|
722
|
+
system: Optional[str] = None,
|
|
723
|
+
temperature: Optional[float] = None,
|
|
724
|
+
provider: Optional[Mapping[str, Any]] = None,
|
|
725
|
+
on_text: Optional[Callable[[str], None]] = None,
|
|
726
|
+
**params: Any,
|
|
727
|
+
) -> RunResult:
|
|
728
|
+
"""One prompt, one answer: ``.text`` and ``.receipt``. With an API key the request is charged to the
|
|
729
|
+
prepaid balance; with a wallet it is paid per request over x402. ``prompt`` is the text, or the
|
|
730
|
+
whole message list. ``on_text`` streams the answer, called with each piece as it arrives."""
|
|
731
|
+
if isinstance(prompt, str):
|
|
732
|
+
messages: List[Mapping[str, Any]] = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
|
|
733
|
+
else:
|
|
734
|
+
messages = list(prompt)
|
|
735
|
+
if max_tokens is not None:
|
|
736
|
+
params["max_tokens"] = max_tokens
|
|
737
|
+
if temperature is not None:
|
|
738
|
+
params["temperature"] = temperature
|
|
739
|
+
if provider is not None:
|
|
740
|
+
params["provider"] = dict(provider)
|
|
741
|
+
stream = on_text is not None
|
|
742
|
+
if self.api_key:
|
|
743
|
+
out = self.chat.completions.create(model, messages, stream=stream, **params)
|
|
744
|
+
elif self.wallet is not None:
|
|
745
|
+
out = self.x402.chat.completions.create(model, messages, stream=stream, **self._pay_kwargs(), **params)
|
|
746
|
+
else:
|
|
747
|
+
raise USDFError(401, "run needs an API key (USDF(api_key=...) or USDF_API_KEY) or a wallet to pay per request (USDF(wallet=...))", "authentication_error", "invalid_api_key")
|
|
748
|
+
if isinstance(out, ChatCompletionStream):
|
|
749
|
+
pieces: List[str] = []
|
|
750
|
+
for chunk in out:
|
|
751
|
+
for c in chunk.get("choices") or []:
|
|
752
|
+
d = (c.get("delta") or {}).get("content")
|
|
753
|
+
if isinstance(d, str) and d:
|
|
754
|
+
pieces.append(d)
|
|
755
|
+
on_text(d) # type: ignore[misc]
|
|
756
|
+
if out.receipt is None:
|
|
757
|
+
raise USDFError(502, "the stream ended without a receipt; look it up with receipts.get", "upstream_error", request_id=out.request_id)
|
|
758
|
+
return RunResult("".join(pieces), out.receipt, out.request_id or out.receipt.request_id, None, out.payment_response)
|
|
759
|
+
return RunResult(out.content or "", out.receipt, out.id, out, out.raw.get("payment_response"))
|
|
760
|
+
|
|
761
|
+
# ── Transport ─────────────────────────────────────────────────────────────
|
|
762
|
+
|
|
763
|
+
def _bearer(self, auth: str) -> Optional[str]:
|
|
764
|
+
if auth == "none":
|
|
765
|
+
return None
|
|
766
|
+
if auth == "key":
|
|
767
|
+
if not self.api_key:
|
|
768
|
+
raise USDFError(401, "an API key is required for this call (USDF(api_key='sk_...') or USDF_API_KEY)", "authentication_error", "invalid_api_key")
|
|
769
|
+
return self.api_key
|
|
770
|
+
if auth == "session":
|
|
771
|
+
if not self.session_token:
|
|
772
|
+
raise USDFError(401, "an account session is required for this call: sign in at usdf.fi and pass its session token (USDF(session_token=...) or USDF_SESSION_TOKEN)", "authentication_error", "unauthenticated")
|
|
773
|
+
return self.session_token
|
|
774
|
+
return self.api_key or self.session_token
|
|
775
|
+
|
|
776
|
+
def _headers(self, auth: str, extra: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
|
|
777
|
+
h = {"accept": "application/json", "user-agent": _UA, **self._extra, **(extra or {})}
|
|
778
|
+
token = self._bearer(auth)
|
|
779
|
+
if token:
|
|
780
|
+
h["authorization"] = f"Bearer {token}"
|
|
781
|
+
return h
|
|
782
|
+
|
|
783
|
+
def _request(self, method: str, path: str, body: Any = None, *, auth: str = "none", timeout: Optional[float] = None, extra_headers: Optional[Mapping[str, str]] = None):
|
|
784
|
+
headers = self._headers(auth, extra_headers)
|
|
785
|
+
data = None
|
|
786
|
+
if body is not None:
|
|
787
|
+
headers["content-type"] = "application/json"
|
|
788
|
+
data = json.dumps(body).encode("utf-8")
|
|
789
|
+
res = self._transport.request(method, f"{self.base_url}{path}", headers, data, timeout or self.timeout)
|
|
790
|
+
if res.status >= 400:
|
|
791
|
+
raise USDFError.from_response(res.status, res.json(), res.headers)
|
|
792
|
+
return res
|
|
793
|
+
|
|
794
|
+
def _json(self, method: str, path: str, body: Any = None, *, auth: str = "none", timeout: Optional[float] = None, extra_headers: Optional[Mapping[str, str]] = None) -> Any:
|
|
795
|
+
return self._request(method, path, body, auth=auth, timeout=timeout, extra_headers=extra_headers).json()
|
|
796
|
+
|
|
797
|
+
def _stream(self, method: str, path: str, body: Any, *, auth: str, timeout: Optional[float], extra_headers: Optional[Mapping[str, str]] = None):
|
|
798
|
+
headers = self._headers(auth, extra_headers)
|
|
799
|
+
headers["accept"] = "text/event-stream"
|
|
800
|
+
headers["content-type"] = "application/json"
|
|
801
|
+
# A stream may run for up to an hour; the caller's timeout bounds it.
|
|
802
|
+
res = self._transport.stream(method, f"{self.base_url}{path}", headers, json.dumps(body).encode("utf-8"), timeout or 60 * 60.0)
|
|
803
|
+
if res.status >= 400:
|
|
804
|
+
data = b"".join(res.chunks)
|
|
805
|
+
try:
|
|
806
|
+
res.close()
|
|
807
|
+
except Exception:
|
|
808
|
+
pass
|
|
809
|
+
try:
|
|
810
|
+
j = json.loads(data.decode("utf-8"))
|
|
811
|
+
except ValueError:
|
|
812
|
+
j = None
|
|
813
|
+
raise USDFError.from_response(res.status, j, res.headers)
|
|
814
|
+
return res
|
|
815
|
+
|
|
816
|
+
# ── Public reads ──────────────────────────────────────────────────────────
|
|
817
|
+
|
|
818
|
+
def pricing(self) -> Pricing:
|
|
819
|
+
"""GET /v1/pricing: the price sheet in force, with routes and data policy per route."""
|
|
820
|
+
return Pricing.of(self._json("GET", "/v1/pricing"))
|
|
821
|
+
|
|
822
|
+
def tools(self) -> Dict[str, Any]:
|
|
823
|
+
"""GET /v1/tools."""
|
|
824
|
+
return self._json("GET", "/v1/tools")
|
|
825
|
+
|
|
826
|
+
# ── MCP tools ─────────────────────────────────────────────────────────────
|
|
827
|
+
|
|
828
|
+
def quote(self, model: str, prompt: Optional[str] = None, messages: Optional[Sequence[Mapping[str, Any]]] = None, max_tokens: Optional[int] = None) -> Quote:
|
|
829
|
+
"""The hold the gateway would place for this request, and the x402 amount. No key needed.
|
|
830
|
+
Over MCP the output is capped at 4096 tokens; for more, estimate with ``usdf.estimate``."""
|
|
831
|
+
args: Dict[str, Any] = {"model": model}
|
|
832
|
+
if prompt is not None:
|
|
833
|
+
args["prompt"] = prompt
|
|
834
|
+
if messages:
|
|
835
|
+
args["messages"] = list(messages)
|
|
836
|
+
if max_tokens is not None:
|
|
837
|
+
args["max_tokens"] = max_tokens
|
|
838
|
+
return Quote.of(self.mcp.call("quote", args, auth="any"))
|
|
839
|
+
|
|
840
|
+
def balance(self) -> Balance:
|
|
841
|
+
"""What the key can spend (MCP ``balance``)."""
|
|
842
|
+
return Balance.of(self.mcp.call("balance", {}, auth="key"))
|