axio-sse 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- axio_sse-0.1.0/.gitignore +14 -0
- axio_sse-0.1.0/PKG-INFO +297 -0
- axio_sse-0.1.0/README.md +285 -0
- axio_sse-0.1.0/pyproject.toml +49 -0
- axio_sse-0.1.0/src/axio_sse/__init__.py +43 -0
- axio_sse-0.1.0/src/axio_sse/decoder.py +263 -0
- axio_sse-0.1.0/src/axio_sse/event.py +104 -0
- axio_sse-0.1.0/src/axio_sse/py.typed +0 -0
- axio_sse-0.1.0/src/axio_sse/reader.py +200 -0
- axio_sse-0.1.0/src/axio_sse/stream.py +37 -0
- axio_sse-0.1.0/src/axio_sse/wire.py +132 -0
- axio_sse-0.1.0/tests/conftest.py +48 -0
- axio_sse-0.1.0/tests/test_decoder.py +396 -0
- axio_sse-0.1.0/tests/test_payload.py +57 -0
- axio_sse-0.1.0/tests/test_reader.py +332 -0
- axio_sse-0.1.0/tests/test_stream.py +31 -0
- axio_sse-0.1.0/tests/test_wire.py +242 -0
axio_sse-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: axio-sse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-sent events, read from a stream of chunks
|
|
5
|
+
Project-URL: Documentation, https://docs.axio-agent.com
|
|
6
|
+
Project-URL: Homepage, https://github.com/mosquito/axio-agent
|
|
7
|
+
Project-URL: Repository, https://github.com/mosquito/axio-agent
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: async,event-stream,server-sent-events,sse,streaming
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# axio-sse
|
|
14
|
+
|
|
15
|
+
[](https://pypi.org/project/axio-sse/)
|
|
16
|
+
[](https://pypi.org/project/axio-sse/)
|
|
17
|
+
[](LICENSE)
|
|
18
|
+
|
|
19
|
+
Read `text/event-stream`: a decoder you feed, and a reader for what its payloads mean.
|
|
20
|
+
|
|
21
|
+
The package knows nothing about HTTP and imports no client. It has no dependencies, not even on
|
|
22
|
+
[axio](https://github.com/mosquito/axio-agent).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install axio-sse
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### `payloads(chunks, *, until="")` — the JSON object of every event
|
|
33
|
+
|
|
34
|
+
All a stream with no discriminator needs. Comments, keep-alives and junk never arrive. `until` names
|
|
35
|
+
the one data payload that closes the stream, so a sentinel that is not JSON never reaches you.
|
|
36
|
+
|
|
37
|
+
<!-- name: test_readme_payloads -->
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from axio_sse import payloads
|
|
41
|
+
|
|
42
|
+
async def chunks():
|
|
43
|
+
yield b': keep-alive\n\n'
|
|
44
|
+
yield b'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n'
|
|
45
|
+
yield b'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n'
|
|
46
|
+
yield b"data: [DONE]"
|
|
47
|
+
|
|
48
|
+
async def main() -> None:
|
|
49
|
+
got = [p["choices"][0]["delta"]["content"] async for p in payloads(chunks(), until="[DONE]")]
|
|
50
|
+
assert got == ["Hel", "lo"]
|
|
51
|
+
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Feed it whatever the transport hands you. A chunk may end mid-field, mid-terminator, or mid-UTF-8
|
|
56
|
+
sequence. The result does not depend on where it was cut. Chunks must still carry their line
|
|
57
|
+
terminators, so `aiter_lines()` will not do. It strips them, and nothing ever dispatches.
|
|
58
|
+
|
|
59
|
+
A stream that stops without its final blank line still yields what it collected. The example above
|
|
60
|
+
ends on `data: [DONE]` with no newline after it. That is how these streams really end.
|
|
61
|
+
|
|
62
|
+
### `events(chunks, *, until="")` — the wire events themselves
|
|
63
|
+
|
|
64
|
+
<!-- name: test_readme_events -->
|
|
65
|
+
```python
|
|
66
|
+
import asyncio
|
|
67
|
+
from axio_sse import Event, events
|
|
68
|
+
|
|
69
|
+
async def chunks():
|
|
70
|
+
yield b'data: {"first":\ndata: true}\n\n'
|
|
71
|
+
yield b"event: named\r\ndata: sec"
|
|
72
|
+
yield b"ond\r\n\r\n"
|
|
73
|
+
|
|
74
|
+
async def main() -> None:
|
|
75
|
+
assert [e async for e in events(chunks())] == [
|
|
76
|
+
Event(data='{"first":\ntrue}'),
|
|
77
|
+
Event(data="second", event="named"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`Event` carries the four fields the format defines — `data`, `event`, `id`, `retry`. An empty
|
|
84
|
+
`event` means unnamed, which the format reads as `"message"`. `Event.payload()` gives the JSON
|
|
85
|
+
object, or `None` where the event carries none.
|
|
86
|
+
|
|
87
|
+
`events()` suspends nowhere of its own accord, so it needs no async framework: asyncio, trio and
|
|
88
|
+
anyio all drive it. A `yield` in an async generator does not reach the event loop. A caller that
|
|
89
|
+
must stay fair to other tasks — a TUI redrawing, a queue being served — therefore says so itself,
|
|
90
|
+
with `await asyncio.sleep(0)` in its own loop where it knows what else is waiting.
|
|
91
|
+
|
|
92
|
+
### `Decoder` — the format, with no loop
|
|
93
|
+
|
|
94
|
+
`Decoder` is the format and nothing else. It is synchronous and holds no connection. Every wire case
|
|
95
|
+
is therefore testable without a loop. A thread or a non-asyncio caller can drive it too. Same shape
|
|
96
|
+
as `codecs.IncrementalDecoder`, because the problem is the same: input cut at arbitrary points,
|
|
97
|
+
output that only sometimes completes.
|
|
98
|
+
|
|
99
|
+
<!-- name: test_readme_decoder -->
|
|
100
|
+
```python
|
|
101
|
+
from axio_sse import Decoder, Event
|
|
102
|
+
|
|
103
|
+
decoder = Decoder()
|
|
104
|
+
assert decoder.decode(b"data: hel") == []
|
|
105
|
+
assert decoder.decode(b"lo\n\ndata: wor") == [Event(data="hello")]
|
|
106
|
+
assert decoder.decode(b"ld\n\n", final=True) == [Event(data="world")]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`final=True` closes the stream. What is still pending is discarded, which the format requires: an
|
|
110
|
+
event that never reached its blank line is not dispatched. Dispatched anyway, a connection cut
|
|
111
|
+
between a frame and the blank line after it makes a truncated turn read as a finished one.
|
|
112
|
+
|
|
113
|
+
The package takes chunks and never lines for a reason. `aiohttp`'s `readuntil` raises `LineTooLong`
|
|
114
|
+
past 131072 bytes. `LineTooLong` is not a `ClientError`. One large reasoning event kills a turn with
|
|
115
|
+
no answer.
|
|
116
|
+
|
|
117
|
+
### `Wire` — a payload shape
|
|
118
|
+
|
|
119
|
+
Declare the fields you read. Each is read by its declared name and type. A misspelled key is
|
|
120
|
+
therefore a type error at the place that uses it, rather than a default quietly standing in for the
|
|
121
|
+
value.
|
|
122
|
+
|
|
123
|
+
<!-- name: test_readme_reader -->
|
|
124
|
+
```python
|
|
125
|
+
from dataclasses import dataclass, field
|
|
126
|
+
from axio_sse import Payload, Wire
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class Usage(Wire):
|
|
130
|
+
"""Nested, and never dispatched to: it has no name of its own."""
|
|
131
|
+
output_tokens: int = 0
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class ResponseObject(Wire):
|
|
135
|
+
usage: Usage = field(default_factory=Usage)
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class OutputTextDelta(Wire, name="response.output_text.delta"):
|
|
139
|
+
delta: str = ""
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class Completed(Wire, name="response.completed"):
|
|
143
|
+
response: ResponseObject = field(default_factory=ResponseObject)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A field the provider did not send, sent as null, or sent as the wrong type takes its default. That
|
|
147
|
+
is what an optional provider field is. One bad field must not lose the whole event. A nested object
|
|
148
|
+
is another `Wire`. A list of them is `list[ThatWire]`. Declare a field `raw: Payload` and it
|
|
149
|
+
receives the whole payload, for a shape that varies too much to declare whole.
|
|
150
|
+
|
|
151
|
+
### `Reader` — one method per event
|
|
152
|
+
|
|
153
|
+
A stream that says what each event is subclasses `Reader` and writes one `@on(...)` method per
|
|
154
|
+
event. That class body is one endpoint's whole vocabulary. `by` on the class line names the payload
|
|
155
|
+
key that holds the event's name. It defaults to `"type"`.
|
|
156
|
+
|
|
157
|
+
Give `@on` a shape and the method is handed that shape. Give it names and the method is handed the
|
|
158
|
+
`Payload` itself. That is what a method that only forwards an event wants. Declaring a shape for a
|
|
159
|
+
payload nobody reads a field of would be a schema written for nothing.
|
|
160
|
+
|
|
161
|
+
One instance reads one stream. The turn's running totals and id maps live on `self` instead of
|
|
162
|
+
travelling through a call. Construct one per response.
|
|
163
|
+
|
|
164
|
+
<!-- name: test_readme_reader -->
|
|
165
|
+
```python
|
|
166
|
+
import asyncio
|
|
167
|
+
from collections.abc import Iterator
|
|
168
|
+
from axio_sse import Reader, on
|
|
169
|
+
|
|
170
|
+
class Responses(Reader[str]):
|
|
171
|
+
"""What the Responses API sends, and what each event becomes."""
|
|
172
|
+
|
|
173
|
+
def __init__(self) -> None:
|
|
174
|
+
self.output_tokens = 0
|
|
175
|
+
|
|
176
|
+
@on(OutputTextDelta)
|
|
177
|
+
def _text(self, wire: OutputTextDelta) -> Iterator[str]:
|
|
178
|
+
yield wire.delta
|
|
179
|
+
|
|
180
|
+
@on(Completed)
|
|
181
|
+
def _completed(self, wire: Completed) -> None:
|
|
182
|
+
self.output_tokens = wire.response.usage.output_tokens
|
|
183
|
+
|
|
184
|
+
@on("response.created", "response.in_progress", "response.output_text.done")
|
|
185
|
+
def _expected(self, payload: Payload) -> None:
|
|
186
|
+
"""The bookkeeping around the deltas. Named so strict fires only on something new."""
|
|
187
|
+
|
|
188
|
+
async def chunks():
|
|
189
|
+
yield b'data: {"type":"response.created"}\n\n'
|
|
190
|
+
yield b'data: {"type":"response.output_text.delta","delta":"Hi"}\n\n'
|
|
191
|
+
yield b'data: {"type":"response.completed","response":{"usage":{"output_tokens":7}}}\n\n'
|
|
192
|
+
|
|
193
|
+
async def main() -> None:
|
|
194
|
+
turn = Responses()
|
|
195
|
+
assert [made async for made in turn.over(chunks())] == ["Hi"]
|
|
196
|
+
assert turn.output_tokens == 7
|
|
197
|
+
|
|
198
|
+
asyncio.run(main())
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
A handler returns what the event became — an iterable, or `None` where the event only moved that
|
|
202
|
+
state. Several names on one method is how a stream that sends one thing under two names is written.
|
|
203
|
+
It is also how a group of events that means nothing here is written: a method with only a docstring.
|
|
204
|
+
Both stay in the class body, so no second list exists to keep in step with the first.
|
|
205
|
+
|
|
206
|
+
### `strict` — failing on the day the provider sends something new
|
|
207
|
+
|
|
208
|
+
An event no method claims is skipped and logged at DEBUG. Read with `strict=True` and it raises
|
|
209
|
+
instead. That is what a test holds against the provider's own published list.
|
|
210
|
+
|
|
211
|
+
<!-- name: test_readme_reader -->
|
|
212
|
+
```python
|
|
213
|
+
import pytest
|
|
214
|
+
from axio_sse import Event, UnknownEvent
|
|
215
|
+
|
|
216
|
+
assert Responses.names() == {
|
|
217
|
+
"response.output_text.delta",
|
|
218
|
+
"response.completed",
|
|
219
|
+
"response.created",
|
|
220
|
+
"response.in_progress",
|
|
221
|
+
"response.output_text.done",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
with pytest.raises(UnknownEvent, match="response.refusal.delta"):
|
|
225
|
+
Responses().read(Event(data='{"type":"response.refusal.delta"}'), strict=True)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`strict` belongs to the call, not to the reader. A policy that outlived one call would leave a CI
|
|
229
|
+
test's strictness set for the next caller.
|
|
230
|
+
|
|
231
|
+
### `EVENT_NAME` — dispatching on the format's own field
|
|
232
|
+
|
|
233
|
+
Some streams name the event in the SSE `event:` field rather than in the payload.
|
|
234
|
+
|
|
235
|
+
<!-- name: test_readme_event_name -->
|
|
236
|
+
```python
|
|
237
|
+
import asyncio
|
|
238
|
+
from collections.abc import Iterator
|
|
239
|
+
from dataclasses import dataclass, field
|
|
240
|
+
from axio_sse import EVENT_NAME, Reader, Wire, on
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True, slots=True)
|
|
243
|
+
class BlockDelta(Wire):
|
|
244
|
+
text: str = ""
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True, slots=True)
|
|
247
|
+
class ContentBlockDelta(Wire, name="content_block_delta"):
|
|
248
|
+
delta: BlockDelta = field(default_factory=BlockDelta)
|
|
249
|
+
|
|
250
|
+
class Messages(Reader[str], by=EVENT_NAME):
|
|
251
|
+
@on(ContentBlockDelta)
|
|
252
|
+
def _delta(self, wire: ContentBlockDelta) -> Iterator[str]:
|
|
253
|
+
yield wire.delta.text
|
|
254
|
+
|
|
255
|
+
async def chunks():
|
|
256
|
+
yield b'event: content_block_delta\ndata: {"delta":{"text":"Hi"}}\n\n'
|
|
257
|
+
yield b"event: ping\ndata: {}\n\n"
|
|
258
|
+
|
|
259
|
+
async def main() -> None:
|
|
260
|
+
assert [made async for made in Messages().over(chunks())] == ["Hi"]
|
|
261
|
+
|
|
262
|
+
asyncio.run(main())
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### `Payload` — reading by path
|
|
266
|
+
|
|
267
|
+
`Payload` is a `dict`, so `payload["x"]`, `in` and `json.dumps` all still work. The four readers
|
|
268
|
+
exist so a handler carries no `Any` and no chain of `.get({})`. Each walks the path and gives the
|
|
269
|
+
default wherever a step is missing, null, or the wrong type. That is what an optional provider field
|
|
270
|
+
is.
|
|
271
|
+
|
|
272
|
+
<!-- name: test_readme_payload -->
|
|
273
|
+
```python
|
|
274
|
+
from axio_sse import Payload
|
|
275
|
+
|
|
276
|
+
payload = Payload({"message": {"usage": {"input_tokens": 7}}, "output": [{"type": "function_call"}]})
|
|
277
|
+
|
|
278
|
+
assert payload.number("message", "usage", "input_tokens") == 7
|
|
279
|
+
assert payload.number("message", "usage", "output_tokens") == 0
|
|
280
|
+
assert payload.number("message", "usage", "output_tokens", default=3) == 3
|
|
281
|
+
assert payload.string("message", "role") == ""
|
|
282
|
+
assert payload.obj("message", "usage") == {"input_tokens": 7}
|
|
283
|
+
assert payload.objs("output") == [{"type": "function_call"}]
|
|
284
|
+
assert payload.objs("nothing") == []
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`number()` never reads a `true` as `1`. `bool` is an `int` in Python, so a flag would otherwise read
|
|
288
|
+
as a count and stay unnoticed:
|
|
289
|
+
|
|
290
|
+
<!-- name: test_readme_payload -->
|
|
291
|
+
```python
|
|
292
|
+
assert Payload({"flag": True}).number("flag") == 0
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT
|
axio_sse-0.1.0/README.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# axio-sse
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/axio-sse/)
|
|
4
|
+
[](https://pypi.org/project/axio-sse/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Read `text/event-stream`: a decoder you feed, and a reader for what its payloads mean.
|
|
8
|
+
|
|
9
|
+
The package knows nothing about HTTP and imports no client. It has no dependencies, not even on
|
|
10
|
+
[axio](https://github.com/mosquito/axio-agent).
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install axio-sse
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### `payloads(chunks, *, until="")` — the JSON object of every event
|
|
21
|
+
|
|
22
|
+
All a stream with no discriminator needs. Comments, keep-alives and junk never arrive. `until` names
|
|
23
|
+
the one data payload that closes the stream, so a sentinel that is not JSON never reaches you.
|
|
24
|
+
|
|
25
|
+
<!-- name: test_readme_payloads -->
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
from axio_sse import payloads
|
|
29
|
+
|
|
30
|
+
async def chunks():
|
|
31
|
+
yield b': keep-alive\n\n'
|
|
32
|
+
yield b'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n'
|
|
33
|
+
yield b'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n'
|
|
34
|
+
yield b"data: [DONE]"
|
|
35
|
+
|
|
36
|
+
async def main() -> None:
|
|
37
|
+
got = [p["choices"][0]["delta"]["content"] async for p in payloads(chunks(), until="[DONE]")]
|
|
38
|
+
assert got == ["Hel", "lo"]
|
|
39
|
+
|
|
40
|
+
asyncio.run(main())
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Feed it whatever the transport hands you. A chunk may end mid-field, mid-terminator, or mid-UTF-8
|
|
44
|
+
sequence. The result does not depend on where it was cut. Chunks must still carry their line
|
|
45
|
+
terminators, so `aiter_lines()` will not do. It strips them, and nothing ever dispatches.
|
|
46
|
+
|
|
47
|
+
A stream that stops without its final blank line still yields what it collected. The example above
|
|
48
|
+
ends on `data: [DONE]` with no newline after it. That is how these streams really end.
|
|
49
|
+
|
|
50
|
+
### `events(chunks, *, until="")` — the wire events themselves
|
|
51
|
+
|
|
52
|
+
<!-- name: test_readme_events -->
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
from axio_sse import Event, events
|
|
56
|
+
|
|
57
|
+
async def chunks():
|
|
58
|
+
yield b'data: {"first":\ndata: true}\n\n'
|
|
59
|
+
yield b"event: named\r\ndata: sec"
|
|
60
|
+
yield b"ond\r\n\r\n"
|
|
61
|
+
|
|
62
|
+
async def main() -> None:
|
|
63
|
+
assert [e async for e in events(chunks())] == [
|
|
64
|
+
Event(data='{"first":\ntrue}'),
|
|
65
|
+
Event(data="second", event="named"),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`Event` carries the four fields the format defines — `data`, `event`, `id`, `retry`. An empty
|
|
72
|
+
`event` means unnamed, which the format reads as `"message"`. `Event.payload()` gives the JSON
|
|
73
|
+
object, or `None` where the event carries none.
|
|
74
|
+
|
|
75
|
+
`events()` suspends nowhere of its own accord, so it needs no async framework: asyncio, trio and
|
|
76
|
+
anyio all drive it. A `yield` in an async generator does not reach the event loop. A caller that
|
|
77
|
+
must stay fair to other tasks — a TUI redrawing, a queue being served — therefore says so itself,
|
|
78
|
+
with `await asyncio.sleep(0)` in its own loop where it knows what else is waiting.
|
|
79
|
+
|
|
80
|
+
### `Decoder` — the format, with no loop
|
|
81
|
+
|
|
82
|
+
`Decoder` is the format and nothing else. It is synchronous and holds no connection. Every wire case
|
|
83
|
+
is therefore testable without a loop. A thread or a non-asyncio caller can drive it too. Same shape
|
|
84
|
+
as `codecs.IncrementalDecoder`, because the problem is the same: input cut at arbitrary points,
|
|
85
|
+
output that only sometimes completes.
|
|
86
|
+
|
|
87
|
+
<!-- name: test_readme_decoder -->
|
|
88
|
+
```python
|
|
89
|
+
from axio_sse import Decoder, Event
|
|
90
|
+
|
|
91
|
+
decoder = Decoder()
|
|
92
|
+
assert decoder.decode(b"data: hel") == []
|
|
93
|
+
assert decoder.decode(b"lo\n\ndata: wor") == [Event(data="hello")]
|
|
94
|
+
assert decoder.decode(b"ld\n\n", final=True) == [Event(data="world")]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`final=True` closes the stream. What is still pending is discarded, which the format requires: an
|
|
98
|
+
event that never reached its blank line is not dispatched. Dispatched anyway, a connection cut
|
|
99
|
+
between a frame and the blank line after it makes a truncated turn read as a finished one.
|
|
100
|
+
|
|
101
|
+
The package takes chunks and never lines for a reason. `aiohttp`'s `readuntil` raises `LineTooLong`
|
|
102
|
+
past 131072 bytes. `LineTooLong` is not a `ClientError`. One large reasoning event kills a turn with
|
|
103
|
+
no answer.
|
|
104
|
+
|
|
105
|
+
### `Wire` — a payload shape
|
|
106
|
+
|
|
107
|
+
Declare the fields you read. Each is read by its declared name and type. A misspelled key is
|
|
108
|
+
therefore a type error at the place that uses it, rather than a default quietly standing in for the
|
|
109
|
+
value.
|
|
110
|
+
|
|
111
|
+
<!-- name: test_readme_reader -->
|
|
112
|
+
```python
|
|
113
|
+
from dataclasses import dataclass, field
|
|
114
|
+
from axio_sse import Payload, Wire
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class Usage(Wire):
|
|
118
|
+
"""Nested, and never dispatched to: it has no name of its own."""
|
|
119
|
+
output_tokens: int = 0
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class ResponseObject(Wire):
|
|
123
|
+
usage: Usage = field(default_factory=Usage)
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True, slots=True)
|
|
126
|
+
class OutputTextDelta(Wire, name="response.output_text.delta"):
|
|
127
|
+
delta: str = ""
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class Completed(Wire, name="response.completed"):
|
|
131
|
+
response: ResponseObject = field(default_factory=ResponseObject)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A field the provider did not send, sent as null, or sent as the wrong type takes its default. That
|
|
135
|
+
is what an optional provider field is. One bad field must not lose the whole event. A nested object
|
|
136
|
+
is another `Wire`. A list of them is `list[ThatWire]`. Declare a field `raw: Payload` and it
|
|
137
|
+
receives the whole payload, for a shape that varies too much to declare whole.
|
|
138
|
+
|
|
139
|
+
### `Reader` — one method per event
|
|
140
|
+
|
|
141
|
+
A stream that says what each event is subclasses `Reader` and writes one `@on(...)` method per
|
|
142
|
+
event. That class body is one endpoint's whole vocabulary. `by` on the class line names the payload
|
|
143
|
+
key that holds the event's name. It defaults to `"type"`.
|
|
144
|
+
|
|
145
|
+
Give `@on` a shape and the method is handed that shape. Give it names and the method is handed the
|
|
146
|
+
`Payload` itself. That is what a method that only forwards an event wants. Declaring a shape for a
|
|
147
|
+
payload nobody reads a field of would be a schema written for nothing.
|
|
148
|
+
|
|
149
|
+
One instance reads one stream. The turn's running totals and id maps live on `self` instead of
|
|
150
|
+
travelling through a call. Construct one per response.
|
|
151
|
+
|
|
152
|
+
<!-- name: test_readme_reader -->
|
|
153
|
+
```python
|
|
154
|
+
import asyncio
|
|
155
|
+
from collections.abc import Iterator
|
|
156
|
+
from axio_sse import Reader, on
|
|
157
|
+
|
|
158
|
+
class Responses(Reader[str]):
|
|
159
|
+
"""What the Responses API sends, and what each event becomes."""
|
|
160
|
+
|
|
161
|
+
def __init__(self) -> None:
|
|
162
|
+
self.output_tokens = 0
|
|
163
|
+
|
|
164
|
+
@on(OutputTextDelta)
|
|
165
|
+
def _text(self, wire: OutputTextDelta) -> Iterator[str]:
|
|
166
|
+
yield wire.delta
|
|
167
|
+
|
|
168
|
+
@on(Completed)
|
|
169
|
+
def _completed(self, wire: Completed) -> None:
|
|
170
|
+
self.output_tokens = wire.response.usage.output_tokens
|
|
171
|
+
|
|
172
|
+
@on("response.created", "response.in_progress", "response.output_text.done")
|
|
173
|
+
def _expected(self, payload: Payload) -> None:
|
|
174
|
+
"""The bookkeeping around the deltas. Named so strict fires only on something new."""
|
|
175
|
+
|
|
176
|
+
async def chunks():
|
|
177
|
+
yield b'data: {"type":"response.created"}\n\n'
|
|
178
|
+
yield b'data: {"type":"response.output_text.delta","delta":"Hi"}\n\n'
|
|
179
|
+
yield b'data: {"type":"response.completed","response":{"usage":{"output_tokens":7}}}\n\n'
|
|
180
|
+
|
|
181
|
+
async def main() -> None:
|
|
182
|
+
turn = Responses()
|
|
183
|
+
assert [made async for made in turn.over(chunks())] == ["Hi"]
|
|
184
|
+
assert turn.output_tokens == 7
|
|
185
|
+
|
|
186
|
+
asyncio.run(main())
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A handler returns what the event became — an iterable, or `None` where the event only moved that
|
|
190
|
+
state. Several names on one method is how a stream that sends one thing under two names is written.
|
|
191
|
+
It is also how a group of events that means nothing here is written: a method with only a docstring.
|
|
192
|
+
Both stay in the class body, so no second list exists to keep in step with the first.
|
|
193
|
+
|
|
194
|
+
### `strict` — failing on the day the provider sends something new
|
|
195
|
+
|
|
196
|
+
An event no method claims is skipped and logged at DEBUG. Read with `strict=True` and it raises
|
|
197
|
+
instead. That is what a test holds against the provider's own published list.
|
|
198
|
+
|
|
199
|
+
<!-- name: test_readme_reader -->
|
|
200
|
+
```python
|
|
201
|
+
import pytest
|
|
202
|
+
from axio_sse import Event, UnknownEvent
|
|
203
|
+
|
|
204
|
+
assert Responses.names() == {
|
|
205
|
+
"response.output_text.delta",
|
|
206
|
+
"response.completed",
|
|
207
|
+
"response.created",
|
|
208
|
+
"response.in_progress",
|
|
209
|
+
"response.output_text.done",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
with pytest.raises(UnknownEvent, match="response.refusal.delta"):
|
|
213
|
+
Responses().read(Event(data='{"type":"response.refusal.delta"}'), strict=True)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`strict` belongs to the call, not to the reader. A policy that outlived one call would leave a CI
|
|
217
|
+
test's strictness set for the next caller.
|
|
218
|
+
|
|
219
|
+
### `EVENT_NAME` — dispatching on the format's own field
|
|
220
|
+
|
|
221
|
+
Some streams name the event in the SSE `event:` field rather than in the payload.
|
|
222
|
+
|
|
223
|
+
<!-- name: test_readme_event_name -->
|
|
224
|
+
```python
|
|
225
|
+
import asyncio
|
|
226
|
+
from collections.abc import Iterator
|
|
227
|
+
from dataclasses import dataclass, field
|
|
228
|
+
from axio_sse import EVENT_NAME, Reader, Wire, on
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True, slots=True)
|
|
231
|
+
class BlockDelta(Wire):
|
|
232
|
+
text: str = ""
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True, slots=True)
|
|
235
|
+
class ContentBlockDelta(Wire, name="content_block_delta"):
|
|
236
|
+
delta: BlockDelta = field(default_factory=BlockDelta)
|
|
237
|
+
|
|
238
|
+
class Messages(Reader[str], by=EVENT_NAME):
|
|
239
|
+
@on(ContentBlockDelta)
|
|
240
|
+
def _delta(self, wire: ContentBlockDelta) -> Iterator[str]:
|
|
241
|
+
yield wire.delta.text
|
|
242
|
+
|
|
243
|
+
async def chunks():
|
|
244
|
+
yield b'event: content_block_delta\ndata: {"delta":{"text":"Hi"}}\n\n'
|
|
245
|
+
yield b"event: ping\ndata: {}\n\n"
|
|
246
|
+
|
|
247
|
+
async def main() -> None:
|
|
248
|
+
assert [made async for made in Messages().over(chunks())] == ["Hi"]
|
|
249
|
+
|
|
250
|
+
asyncio.run(main())
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### `Payload` — reading by path
|
|
254
|
+
|
|
255
|
+
`Payload` is a `dict`, so `payload["x"]`, `in` and `json.dumps` all still work. The four readers
|
|
256
|
+
exist so a handler carries no `Any` and no chain of `.get({})`. Each walks the path and gives the
|
|
257
|
+
default wherever a step is missing, null, or the wrong type. That is what an optional provider field
|
|
258
|
+
is.
|
|
259
|
+
|
|
260
|
+
<!-- name: test_readme_payload -->
|
|
261
|
+
```python
|
|
262
|
+
from axio_sse import Payload
|
|
263
|
+
|
|
264
|
+
payload = Payload({"message": {"usage": {"input_tokens": 7}}, "output": [{"type": "function_call"}]})
|
|
265
|
+
|
|
266
|
+
assert payload.number("message", "usage", "input_tokens") == 7
|
|
267
|
+
assert payload.number("message", "usage", "output_tokens") == 0
|
|
268
|
+
assert payload.number("message", "usage", "output_tokens", default=3) == 3
|
|
269
|
+
assert payload.string("message", "role") == ""
|
|
270
|
+
assert payload.obj("message", "usage") == {"input_tokens": 7}
|
|
271
|
+
assert payload.objs("output") == [{"type": "function_call"}]
|
|
272
|
+
assert payload.objs("nothing") == []
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`number()` never reads a `true` as `1`. `bool` is an `int` in Python, so a flag would otherwise read
|
|
276
|
+
as a count and stay unnoticed:
|
|
277
|
+
|
|
278
|
+
<!-- name: test_readme_payload -->
|
|
279
|
+
```python
|
|
280
|
+
assert Payload({"flag": True}).number("flag") == 0
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## License
|
|
284
|
+
|
|
285
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "axio-sse"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Server-sent events, read from a stream of chunks"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
keywords = ["sse", "server-sent-events", "event-stream", "streaming", "async"]
|
|
9
|
+
# Nothing. It takes an async iterable of bytes and yields events; whoever produced the bytes is
|
|
10
|
+
# not its business, which is the whole reason it is its own distribution.
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Documentation = "https://docs.axio-agent.com"
|
|
15
|
+
Homepage = "https://github.com/mosquito/axio-agent"
|
|
16
|
+
Repository = "https://github.com/mosquito/axio-agent"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/axio_sse"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
asyncio_mode = "auto"
|
|
27
|
+
testpaths = ["tests", "README.md"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 119
|
|
31
|
+
output-format = "concise"
|
|
32
|
+
target-version = "py312"
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "I", "UP"]
|
|
36
|
+
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
strict = true
|
|
39
|
+
python_version = "3.12"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8",
|
|
44
|
+
"pytest-asyncio>=0.24",
|
|
45
|
+
"mypy>=1.14",
|
|
46
|
+
"ruff>=0.9",
|
|
47
|
+
"pytest-cov>=7.1.0",
|
|
48
|
+
"markdown-pytest>=0.6.0",
|
|
49
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Read ``text/event-stream``: a decoder you feed, and a reader for what its payloads mean.
|
|
2
|
+
|
|
3
|
+
``Decoder`` is the format and nothing else. Feed it chunks — bytes or text, cut anywhere — and take
|
|
4
|
+
the events they completed. It is synchronous and holds no connection, so every wire case is
|
|
5
|
+
testable without a loop, and a thread or a non-asyncio caller can drive it. ``events()`` and
|
|
6
|
+
``payloads()`` are the async skin over it: chunks in, ``Event`` or ``Payload`` out. Chunks must
|
|
7
|
+
carry their line terminators, so an iterator of lines will not do.
|
|
8
|
+
|
|
9
|
+
A stream whose events are all one shape needs nothing above ``payloads()``: every JSON object the
|
|
10
|
+
stream carries, and nothing more to learn. ``until`` names the one data payload that closes the
|
|
11
|
+
stream — ``until="[DONE]"`` — so a sentinel that is not JSON never reaches a caller.
|
|
12
|
+
|
|
13
|
+
A stream that says what each event is subclasses ``Reader`` and writes one ``@on(...)`` method per
|
|
14
|
+
event. ``by`` on the class line names the payload key that holds the name, or ``EVENT_NAME`` for
|
|
15
|
+
the format's own ``event:`` field. That class body is one endpoint's whole vocabulary, the events
|
|
16
|
+
it deliberately drops included. An event no method claims is skipped and logged at DEBUG. It
|
|
17
|
+
raises ``UnknownEvent`` when the caller reads with ``strict=True``, which is how a test fails on
|
|
18
|
+
the day the provider sends something new.
|
|
19
|
+
|
|
20
|
+
This module knows nothing about HTTP and imports no client.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .decoder import Decoder, EventTooLarge
|
|
24
|
+
from .event import Event, MalformedPayload, Payload
|
|
25
|
+
from .reader import EVENT_NAME, Handled, Reader, UnknownEvent, on
|
|
26
|
+
from .stream import events, payloads
|
|
27
|
+
from .wire import Wire
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"EVENT_NAME",
|
|
31
|
+
"Decoder",
|
|
32
|
+
"Event",
|
|
33
|
+
"EventTooLarge",
|
|
34
|
+
"Handled",
|
|
35
|
+
"MalformedPayload",
|
|
36
|
+
"Payload",
|
|
37
|
+
"Reader",
|
|
38
|
+
"UnknownEvent",
|
|
39
|
+
"Wire",
|
|
40
|
+
"events",
|
|
41
|
+
"on",
|
|
42
|
+
"payloads",
|
|
43
|
+
]
|