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/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"))