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/errors.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, Optional
4
+
5
+
6
+ # What to do about a refusal only the person can clear, by its code (appended to str(), and ``hint``).
7
+ ERROR_HINTS = {
8
+ "reauth_required": "this needs a session signed in within the last few minutes (max_age_seconds in the body): confirm it's you on the site, or sign in again and use the new session token; API keys are never asked",
9
+ "recent_link_hold": "a sign-in method was added to this account in the last 24 hours: money leaves it from lifts_at in the body, or once you confirm it's you on the site with a method it had before",
10
+ }
11
+
12
+
13
+ class USDFError(Exception):
14
+ """The gateway's error envelope ({"error": {"message", "type", "code"}}) with its HTTP status.
15
+
16
+ ``retry_after`` is the Retry-After header in seconds when the gateway sent one (rate limits,
17
+ payment back-off); ``request_id`` the x-request-id header where there is one.
18
+ """
19
+
20
+ def __init__(self, status: int, message: str, type: str = "error", code: Optional[str] = None, *, request_id: Optional[str] = None, retry_after: Optional[int] = None, body: Any = None):
21
+ super().__init__(message)
22
+ self.status = status
23
+ self.message = message
24
+ self.type = type
25
+ self.code = code
26
+ self.request_id = request_id
27
+ self.retry_after = retry_after
28
+ self.body = body
29
+ self.hint = ERROR_HINTS.get(code) if code else None
30
+
31
+ def __str__(self) -> str:
32
+ code = f" {self.code}" if self.code else ""
33
+ hint = f" ({self.hint})" if self.hint else ""
34
+ return f"{self.status}{code}: {self.message}{hint}"
35
+
36
+ @classmethod
37
+ def from_response(cls, status: int, body: Any, headers: Optional[Mapping[str, str]] = None) -> "USDFError":
38
+ headers = headers or {}
39
+ err = body.get("error") if isinstance(body, dict) else None
40
+ if isinstance(err, dict):
41
+ message = err.get("message") if isinstance(err.get("message"), str) else f"gateway answered {status}"
42
+ type_ = err.get("type") if isinstance(err.get("type"), str) else "error"
43
+ code = err.get("code") if isinstance(err.get("code"), str) else None
44
+ else:
45
+ # The auth service answers {error, error_description} or {msg, error_code}; PostgREST
46
+ # answers {code, message, details, hint}.
47
+ message = None
48
+ code = None
49
+ if isinstance(body, dict):
50
+ message = body.get("error_description") or body.get("msg") or body.get("message") or (err if isinstance(err, str) else None)
51
+ raw_code = body.get("error_code") or body.get("code") or (err if isinstance(err, str) else None)
52
+ code = str(raw_code) if raw_code else None
53
+ message = str(message) if message else f"gateway answered {status}"
54
+ type_ = "auth_error" if isinstance(body, dict) and err is None else "error"
55
+ retry = headers.get("retry-after")
56
+ return cls(status, message, type_, code, request_id=headers.get("x-request-id"), retry_after=int(retry) if retry and retry.isdigit() else None, body=body)
usdf/estimate.py ADDED
@@ -0,0 +1,123 @@
1
+ """The gateway's hold arithmetic (gateway/src/pricing.ts), reproduced for client-side estimates.
2
+
3
+ Costs are whole units (1 unit = $0.000001 = 0.000001 USDF), rounded up. The gateway's own figures
4
+ bind: ``USDF.quote`` asks it, and the receipt reports what was charged.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Dict, Iterable, Mapping, Optional
11
+
12
+ IMAGE_TOKENS = 16_384
13
+
14
+
15
+ def _bytes(v: Any) -> int:
16
+ if isinstance(v, str):
17
+ return len(v.encode("utf-8"))
18
+ return len(json.dumps(v if v is not None else "", separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
19
+
20
+
21
+ def prompt_shape(messages: Optional[Iterable[Mapping[str, Any]]] = None, prompt: Optional[str] = None, tools: Any = None, response_format: Any = None) -> Dict[str, int]:
22
+ text_bytes = 0
23
+ images = 0
24
+ n = 0
25
+ for m in messages or []:
26
+ n += 1
27
+ text_bytes += _bytes(m.get("role")) + _bytes(m.get("name") or "")
28
+ c = m.get("content")
29
+ if isinstance(c, str):
30
+ text_bytes += _bytes(c)
31
+ elif isinstance(c, list):
32
+ for p in c:
33
+ t = p.get("type") if isinstance(p, dict) else None
34
+ if t in ("image_url", "input_image", "image"):
35
+ images += 1
36
+ elif t == "text":
37
+ text_bytes += _bytes(p.get("text"))
38
+ else:
39
+ text_bytes += _bytes(p)
40
+ elif c is not None:
41
+ text_bytes += _bytes(c)
42
+ if m.get("tool_calls"):
43
+ text_bytes += _bytes(m["tool_calls"])
44
+ if prompt is not None:
45
+ text_bytes += _bytes(prompt)
46
+ if isinstance(tools, list):
47
+ text_bytes += _bytes(tools)
48
+ if response_format is not None:
49
+ text_bytes += _bytes(response_format)
50
+ return {"text_bytes": text_bytes, "images": images, "messages": max(n, 1)}
51
+
52
+
53
+ _ESTIMATE_KWARGS = {"tools", "response_format", "context"}
54
+
55
+
56
+ def _check_kwargs(kw: Dict[str, Any]) -> None:
57
+ extra = set(kw) - _ESTIMATE_KWARGS
58
+ if extra:
59
+ raise TypeError(f"unsupported keyword argument(s): {', '.join(sorted(extra))}")
60
+
61
+
62
+ def estimate_prompt_tokens(messages=None, prompt=None, image_tokens: int = IMAGE_TOKENS, context: Optional[int] = None, **kw) -> int:
63
+ """Prompt tokens the gateway holds for: two bytes per token plus per-image and per-message overhead,
64
+ capped at ``context`` (the model's context window), as the gateway and the TS SDK both cap it."""
65
+ _check_kwargs(kw)
66
+ s = prompt_shape(messages, prompt, kw.get("tools"), kw.get("response_format"))
67
+ n = -(-s["text_bytes"] // 2) + s["images"] * image_tokens + 8 * s["messages"] + 16
68
+ return min(n, context) if context else n
69
+
70
+
71
+ def max_prompt_tokens(messages=None, prompt=None, image_tokens: int = IMAGE_TOKENS, context: Optional[int] = None, **kw) -> int:
72
+ """Prompt tokens at one per UTF-8 byte: the bound the x402 quote uses, capped at ``context``."""
73
+ _check_kwargs(kw)
74
+ s = prompt_shape(messages, prompt, kw.get("tools"), kw.get("response_format"))
75
+ n = estimate_prompt_tokens(messages, prompt, image_tokens, **kw) + s["text_bytes"] // 2
76
+ return min(n, context) if context else n
77
+
78
+
79
+ def _per_m(usd_per_m: float) -> int:
80
+ return int(round(usd_per_m * 1_000_000))
81
+
82
+
83
+ def _ceil_div(a: int, b: int) -> int:
84
+ return -(-a // b)
85
+
86
+
87
+ def _rates(price: Mapping[str, Any], prompt_tokens: int):
88
+ lc = price.get("long_context")
89
+ p = lc if lc and prompt_tokens > lc["above_prompt_tokens"] else price
90
+ inp = _per_m(p["input"])
91
+ cached = inp if p.get("cached_input") is None else _per_m(p["cached_input"])
92
+ # The public Receipt reports one combined cache_write_tokens (not split 5m/1h): billed at whichever
93
+ # write rate the sheet publishes (5m, else 1h, else the plain input rate).
94
+ if p.get("cache_write_5m") is not None:
95
+ write = _per_m(p["cache_write_5m"])
96
+ elif p.get("cache_write_1h") is not None:
97
+ write = _per_m(p["cache_write_1h"])
98
+ else:
99
+ write = inp
100
+ return inp, _per_m(p["output"]), cached, write
101
+
102
+
103
+ def max_cost_units(price: Mapping[str, Any], prompt_tokens: int, max_output: int) -> int:
104
+ """The hold for a prompt of ``prompt_tokens`` and an output budget of ``max_output``."""
105
+ inp, out, _, _ = _rates(price, prompt_tokens)
106
+ return _ceil_div(max(0, prompt_tokens) * inp + max(0, max_output) * out, 1_000_000)
107
+
108
+
109
+ def cost_units(price: Mapping[str, Any], prompt_tokens: int, completion_tokens: int, cached_tokens: int = 0, cache_write_tokens: int = 0) -> int:
110
+ """The charge for reported usage."""
111
+ inp, out, cached_rate, write_rate = _rates(price, prompt_tokens)
112
+ cached = max(0, cached_tokens)
113
+ write = max(0, cache_write_tokens)
114
+ uncached = max(0, max(0, prompt_tokens) - cached - write)
115
+ return _ceil_div(uncached * inp + cached * cached_rate + write * write_rate + max(0, completion_tokens) * out, 1_000_000)
116
+
117
+
118
+ def units(v: int) -> Dict[str, str]:
119
+ """Units as the gateway renders them: {units, usd, usdf} with six decimals."""
120
+ neg = v < 0
121
+ a = -v if neg else v
122
+ usd = f"{'-' if neg else ''}{a // 1_000_000}.{a % 1_000_000:06d}"
123
+ return {"units": str(v), "usd": usd, "usdf": usd}
usdf/langchain.py ADDED
@@ -0,0 +1,142 @@
1
+ """LangChain chat model over the USDF gateway: ``USDFChat(model=..., wallet=wallet)`` pays each call over
2
+ x402 with no account; ``USDFChat(model=..., api_key="sk_...")`` (or USDF_API_KEY) charges a prepaid
3
+ balance. ``invoke`` returns an ``AIMessage`` whose ``response_metadata["receipt"]`` is the gateway's
4
+ receipt and whose ``usage_metadata`` holds the token counts. Needs ``langchain-core``
5
+ (``pip install usdf langchain``).
6
+
7
+ For a plain OpenAI-compatible client in LangChain, ``ChatOpenAI(base_url=".../v1", api_key=...)``
8
+ works as well (docs/agents.md); this class adds the wallet path and the receipt.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Union
14
+
15
+ from langchain_core.callbacks import CallbackManagerForLLMRun
16
+ from langchain_core.language_models.chat_models import BaseChatModel
17
+ from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
18
+ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
19
+ from pydantic import ConfigDict, Field, PrivateAttr, SecretStr
20
+
21
+ from .client import USDF
22
+
23
+ try: # langchain-core 0.3 and later
24
+ from langchain_core.messages import convert_to_openai_messages
25
+ except ImportError: # pragma: no cover - older cores
26
+ convert_to_openai_messages = None # type: ignore[assignment]
27
+
28
+
29
+ def _openai_messages(messages: List[BaseMessage]) -> List[Dict[str, Any]]:
30
+ if convert_to_openai_messages is not None:
31
+ return list(convert_to_openai_messages(messages))
32
+ roles = {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"}
33
+ out: List[Dict[str, Any]] = []
34
+ for m in messages:
35
+ d: Dict[str, Any] = {"role": roles.get(m.type, m.type), "content": m.content}
36
+ if m.type == "tool":
37
+ d["tool_call_id"] = getattr(m, "tool_call_id", None)
38
+ out.append(d)
39
+ return out
40
+
41
+
42
+ def _usage(receipt: Any) -> Optional[Dict[str, int]]:
43
+ u = getattr(receipt, "usage", None)
44
+ if u is None:
45
+ return None
46
+ return {"input_tokens": u.prompt_tokens, "output_tokens": u.completion_tokens, "total_tokens": u.prompt_tokens + u.completion_tokens}
47
+
48
+
49
+ class USDFChat(BaseChatModel):
50
+ """A chat model on the USDF gateway, paid from a wallet (x402) or a prepaid key."""
51
+
52
+ model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)
53
+
54
+ model: str
55
+ # An eth-account account (``Account.from_key(...)``) or a private key: pays each call over x402. Kept
56
+ # out of repr, model_dump and the serialized form LangChain hands to callbacks and tracers.
57
+ wallet: Any = Field(default=None, repr=False, exclude=True)
58
+ # An API key (sk_...): charges the prepaid balance. Unset, USDF_API_KEY is read unless a wallet is
59
+ # given. A secret: masked in repr, left out of model_dump, named in lc_secrets.
60
+ api_key: Optional[SecretStr] = Field(default=None, repr=False, exclude=True)
61
+ # Gateway origin without /v1; unset, USDF_BASE_URL, else the production gateway.
62
+ base_url: Optional[str] = None
63
+ max_tokens: Optional[int] = None
64
+ temperature: Optional[float] = None
65
+ # Privacy and routing asks, sent as the request's `provider` field.
66
+ provider: Optional[Dict[str, Any]] = None
67
+ # x402: "USDF" or "USDG" (default: USDF when its Permit2 allowance covers the amount, else USDG),
68
+ # the one-time Permit2 approval, and where to read the allowance.
69
+ asset: Optional[str] = None
70
+ approve: bool = False
71
+ rpc_url: Optional[str] = None
72
+ # x402: the cap on one payment in base units (default USDF_MAX_AMOUNT, else 1 USDF) and the payee the
73
+ # gateway must name (default USDF_PAY_TO, else the gateway's hot wallet).
74
+ max_amount: Optional[int] = None
75
+ pay_to: Optional[Union[str, Sequence[str]]] = None
76
+
77
+ _client: Optional[USDF] = PrivateAttr(default=None)
78
+
79
+ @property
80
+ def lc_secrets(self) -> Dict[str, str]:
81
+ return {"api_key": "USDF_API_KEY"}
82
+
83
+ @property
84
+ def client(self) -> USDF:
85
+ if self._client is None:
86
+ key = self.api_key.get_secret_value() if self.api_key is not None else None
87
+ self._client = USDF(api_key=key, wallet=self.wallet, base_url=self.base_url, asset=self.asset, approve=self.approve, rpc_url=self.rpc_url, max_amount=self.max_amount, pay_to=self.pay_to)
88
+ return self._client
89
+
90
+ @property
91
+ def _llm_type(self) -> str:
92
+ return "usdf"
93
+
94
+ @property
95
+ def _identifying_params(self) -> Dict[str, Any]:
96
+ return {"model": self.model, "base_url": self.client.base_url, "pays_with": self.client.pays_with}
97
+
98
+ def _params(self, stop: Optional[List[str]], kwargs: Mapping[str, Any]) -> Dict[str, Any]:
99
+ p: Dict[str, Any] = {}
100
+ if self.max_tokens is not None:
101
+ p["max_tokens"] = self.max_tokens
102
+ if self.temperature is not None:
103
+ p["temperature"] = self.temperature
104
+ if self.provider is not None:
105
+ p["provider"] = self.provider
106
+ if stop:
107
+ p["stop"] = stop
108
+ p.update({k: v for k, v in kwargs.items() if v is not None})
109
+ return p
110
+
111
+ def _generate(self, messages: List[BaseMessage], stop: Optional[List[str]] = None, run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any) -> ChatResult:
112
+ r = self.client.run(self.model, _openai_messages(messages), **self._params(stop, kwargs))
113
+ receipt = getattr(r.receipt, "raw", r.receipt)
114
+ message = AIMessage(
115
+ content=r.text,
116
+ id=r.id,
117
+ response_metadata={"id": r.id, "model_name": getattr(r.receipt, "model", self.model), "receipt": receipt, **({"payment": r.payment} if r.payment else {})},
118
+ usage_metadata=_usage(r.receipt), # type: ignore[arg-type]
119
+ )
120
+ return ChatResult(generations=[ChatGeneration(message=message)], llm_output={"id": r.id, "receipt": receipt})
121
+
122
+ def _stream(self, messages: List[BaseMessage], stop: Optional[List[str]] = None, run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any) -> Iterator[ChatGenerationChunk]:
123
+ c = self.client
124
+ params = self._params(stop, kwargs)
125
+ msgs = _openai_messages(messages)
126
+ if c.api_key:
127
+ s = c.chat.completions.create(self.model, msgs, stream=True, **params)
128
+ else:
129
+ s = c.x402.chat.completions.create(self.model, msgs, stream=True, **c._pay_kwargs(), **params)
130
+ for chunk in s: # type: ignore[union-attr]
131
+ for ch in chunk.get("choices") or []:
132
+ d = (ch.get("delta") or {}).get("content")
133
+ if isinstance(d, str) and d:
134
+ out = ChatGenerationChunk(message=AIMessageChunk(content=d, id=s.request_id)) # type: ignore[union-attr]
135
+ if run_manager:
136
+ run_manager.on_llm_new_token(d, chunk=out)
137
+ yield out
138
+ receipt = s.receipt # type: ignore[union-attr]
139
+ yield ChatGenerationChunk(message=AIMessageChunk(content="", id=s.request_id, response_metadata={"id": s.request_id, "receipt": getattr(receipt, "raw", receipt)}, usage_metadata=_usage(receipt))) # type: ignore[union-attr,arg-type]
140
+
141
+
142
+ __all__ = ["USDFChat"]
usdf/sse.py ADDED
@@ -0,0 +1,49 @@
1
+ """Server-sent events as the gateway writes them: ``data:`` payloads and comment lines (``: receipt
2
+ {...}``), which carry the receipt of a stream and are dropped by every standard client."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Iterable, Iterator, Tuple
7
+
8
+ Event = Tuple[str, str] # ("data", payload) or ("comment", text)
9
+
10
+
11
+ def read_sse(chunks: Iterable[bytes]) -> Iterator[Event]:
12
+ buf = b""
13
+ data: list = []
14
+ for chunk in chunks:
15
+ buf += chunk
16
+ while True:
17
+ nl = buf.find(b"\n")
18
+ if nl < 0:
19
+ break
20
+ line = buf[:nl]
21
+ buf = buf[nl + 1 :]
22
+ ev = _line(line, data)
23
+ if ev is not None:
24
+ yield ev
25
+ if buf:
26
+ ev = _line(buf, data)
27
+ if ev is not None:
28
+ yield ev
29
+ if data:
30
+ yield ("data", "\n".join(data))
31
+
32
+
33
+ def _line(raw: bytes, data: list):
34
+ if raw.endswith(b"\r"):
35
+ raw = raw[:-1]
36
+ if raw == b"":
37
+ if not data:
38
+ return None
39
+ payload = "\n".join(data)
40
+ data.clear()
41
+ return ("data", payload)
42
+ text = raw.decode("utf-8", errors="replace")
43
+ if text.startswith(":"):
44
+ t = text[1:]
45
+ return ("comment", t[1:] if t.startswith(" ") else t)
46
+ if text.startswith("data:"):
47
+ v = text[5:]
48
+ data.append(v[1:] if v.startswith(" ") else v)
49
+ return None