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