axio-responses 0.11.0__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.
- axio_responses/__init__.py +67 -0
- axio_responses/py.typed +0 -0
- axio_responses/reader.py +460 -0
- axio_responses/request.py +206 -0
- axio_responses-0.11.0.dist-info/METADATA +106 -0
- axio_responses-0.11.0.dist-info/RECORD +7 -0
- axio_responses-0.11.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The OpenAI Responses API as axio speaks it.
|
|
2
|
+
|
|
3
|
+
``convert_messages`` and ``convert_tools`` build the request. ``Responses`` reads the stream it
|
|
4
|
+
answers with. Both halves are here rather than in a transport because two transports speak this
|
|
5
|
+
API: the public ``/v1/responses`` endpoint and the ChatGPT backend Codex uses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .reader import (
|
|
11
|
+
Annotation,
|
|
12
|
+
AnnotationAdded,
|
|
13
|
+
AnnotationSource,
|
|
14
|
+
ArgumentsDelta,
|
|
15
|
+
ArgumentsDone,
|
|
16
|
+
Completed,
|
|
17
|
+
ContentPartDone,
|
|
18
|
+
Created,
|
|
19
|
+
Failed,
|
|
20
|
+
Incomplete,
|
|
21
|
+
IncompleteDetails,
|
|
22
|
+
InputDetails,
|
|
23
|
+
ItemAdded,
|
|
24
|
+
ItemDone,
|
|
25
|
+
OutputDetails,
|
|
26
|
+
OutputItem,
|
|
27
|
+
ReasoningDeltaEvent,
|
|
28
|
+
RefusalDeltaEvent,
|
|
29
|
+
ResponseError,
|
|
30
|
+
ResponseObject,
|
|
31
|
+
Responses,
|
|
32
|
+
ResponseUsage,
|
|
33
|
+
StreamFailure,
|
|
34
|
+
TextDeltaEvent,
|
|
35
|
+
)
|
|
36
|
+
from .request import STOP_REASONS, convert_messages, convert_tools, tool_output
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"STOP_REASONS",
|
|
40
|
+
"Annotation",
|
|
41
|
+
"AnnotationAdded",
|
|
42
|
+
"AnnotationSource",
|
|
43
|
+
"ArgumentsDelta",
|
|
44
|
+
"ArgumentsDone",
|
|
45
|
+
"ContentPartDone",
|
|
46
|
+
"Completed",
|
|
47
|
+
"Created",
|
|
48
|
+
"Failed",
|
|
49
|
+
"Incomplete",
|
|
50
|
+
"IncompleteDetails",
|
|
51
|
+
"InputDetails",
|
|
52
|
+
"ItemAdded",
|
|
53
|
+
"ItemDone",
|
|
54
|
+
"OutputDetails",
|
|
55
|
+
"OutputItem",
|
|
56
|
+
"ReasoningDeltaEvent",
|
|
57
|
+
"RefusalDeltaEvent",
|
|
58
|
+
"ResponseError",
|
|
59
|
+
"ResponseObject",
|
|
60
|
+
"ResponseUsage",
|
|
61
|
+
"Responses",
|
|
62
|
+
"StreamFailure",
|
|
63
|
+
"TextDeltaEvent",
|
|
64
|
+
"convert_messages",
|
|
65
|
+
"convert_tools",
|
|
66
|
+
"tool_output",
|
|
67
|
+
]
|
axio_responses/py.typed
ADDED
|
File without changes
|
axio_responses/reader.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Reading a Responses stream: the payload shapes, and what each event becomes.
|
|
2
|
+
|
|
3
|
+
The vocabulary is the published ``ResponseStreamEvent`` union. An event missing from ``Responses``
|
|
4
|
+
is one the API added after this was written, not one nobody named. A test reading with
|
|
5
|
+
``strict=True`` holds that against the schema.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
from axio.events import (
|
|
16
|
+
BlockEnd,
|
|
17
|
+
Citation,
|
|
18
|
+
IterationEnd,
|
|
19
|
+
IterationStart,
|
|
20
|
+
ProviderEvent,
|
|
21
|
+
ProviderOutput,
|
|
22
|
+
ReasoningDelta,
|
|
23
|
+
ReasoningSignature,
|
|
24
|
+
Refusal,
|
|
25
|
+
StreamEvent,
|
|
26
|
+
TextDelta,
|
|
27
|
+
ToolInputDelta,
|
|
28
|
+
ToolUseStart,
|
|
29
|
+
)
|
|
30
|
+
from axio.exceptions import StreamError
|
|
31
|
+
from axio.types import StopReason, Usage, stop_reason_from
|
|
32
|
+
from axio_sse import Payload, Reader, Wire, on
|
|
33
|
+
|
|
34
|
+
from .request import PROVIDER, STOP_REASONS
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("axio.responses")
|
|
37
|
+
|
|
38
|
+
#: Item types this reader turns into content of its own. Everything else is the API's own tooling
|
|
39
|
+
#: — a search it ran, a file it read, code it executed — and has to travel back unread.
|
|
40
|
+
_INTERPRETED: Final = frozenset({"message", "reasoning", "function_call"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class InputDetails(Wire):
|
|
45
|
+
cached_tokens: int = 0
|
|
46
|
+
cache_write_tokens: int = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class OutputDetails(Wire):
|
|
51
|
+
reasoning_tokens: int = 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ResponseUsage(Wire):
|
|
56
|
+
"""Both slices arrive inside their totals here, so the reader adds nothing to either."""
|
|
57
|
+
|
|
58
|
+
input_tokens: int = 0
|
|
59
|
+
output_tokens: int = 0
|
|
60
|
+
input_tokens_details: InputDetails = field(default_factory=InputDetails)
|
|
61
|
+
output_tokens_details: OutputDetails = field(default_factory=OutputDetails)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class OutputItem(Wire):
|
|
66
|
+
type: str = ""
|
|
67
|
+
id: str = ""
|
|
68
|
+
call_id: str = ""
|
|
69
|
+
name: str = ""
|
|
70
|
+
#: Present on a ``reasoning`` item when the request asked for it. Opaque, and sent back on
|
|
71
|
+
#: the next request.
|
|
72
|
+
encrypted_content: str = ""
|
|
73
|
+
#: The item exactly as it arrived. An item this reader does not interpret has to go back whole
|
|
74
|
+
#: on the next request, and no shape declared here would hold a type nobody has published yet.
|
|
75
|
+
raw: Payload = field(default_factory=Payload)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class IncompleteDetails(Wire):
|
|
80
|
+
reason: str = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class ResponseError(Wire):
|
|
85
|
+
message: str = ""
|
|
86
|
+
code: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class ResponseObject(Wire):
|
|
91
|
+
id: str = ""
|
|
92
|
+
model: str = ""
|
|
93
|
+
status: str = ""
|
|
94
|
+
usage: ResponseUsage = field(default_factory=ResponseUsage)
|
|
95
|
+
output: list[OutputItem] = field(default_factory=list)
|
|
96
|
+
error: ResponseError = field(default_factory=ResponseError)
|
|
97
|
+
incomplete_details: IncompleteDetails = field(default_factory=IncompleteDetails)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class AnnotationSource(Wire):
|
|
102
|
+
url: str = ""
|
|
103
|
+
filename: str = ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class Annotation(Wire):
|
|
108
|
+
"""One attribution. It arrives under a url shape, a file shape and the older flat citation, so
|
|
109
|
+
each field is declared wherever a shape put it. The whole object travels in ``raw``."""
|
|
110
|
+
|
|
111
|
+
text: str = ""
|
|
112
|
+
title: str | None = None
|
|
113
|
+
url: str | None = None
|
|
114
|
+
file_id: str | None = None
|
|
115
|
+
source: AnnotationSource = field(default_factory=AnnotationSource)
|
|
116
|
+
start_index: int | None = None
|
|
117
|
+
end_index: int | None = None
|
|
118
|
+
raw: Payload = field(default_factory=Payload)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class Created(Wire, name="response.created"):
|
|
123
|
+
response: ResponseObject = field(default_factory=ResponseObject)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class Completed(Wire, name="response.completed"):
|
|
128
|
+
response: ResponseObject = field(default_factory=ResponseObject)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class Incomplete(Wire, name="response.incomplete"):
|
|
133
|
+
response: ResponseObject = field(default_factory=ResponseObject)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class Failed(Wire, name="response.failed"):
|
|
138
|
+
response: ResponseObject = field(default_factory=ResponseObject)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class StreamFailure(Wire, name="error"):
|
|
143
|
+
"""The stream's own error, which carries no response object."""
|
|
144
|
+
|
|
145
|
+
message: str = ""
|
|
146
|
+
code: str = ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class TextDeltaEvent(Wire, name="response.output_text.delta"):
|
|
151
|
+
delta: str = ""
|
|
152
|
+
#: Which output item this belongs to. Fixed at zero, every delta of a multi-item response
|
|
153
|
+
#: shared one index while the events that close a block kept the real one.
|
|
154
|
+
output_index: int = 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True, slots=True)
|
|
158
|
+
class ReasoningDeltaEvent(Wire, name="response.reasoning_summary_text.delta", also="response.reasoning_text.delta"):
|
|
159
|
+
"""Both reasoning channels read the same. A model that sends the text rather than the summary
|
|
160
|
+
would otherwise think in silence."""
|
|
161
|
+
|
|
162
|
+
delta: str = ""
|
|
163
|
+
output_index: int = 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class RefusalDeltaEvent(Wire, name="response.refusal.delta"):
|
|
168
|
+
delta: str = ""
|
|
169
|
+
output_index: int = 0
|
|
170
|
+
raw: Payload = field(default_factory=Payload)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class AnnotationAdded(Wire, name="response.output_text.annotation.added"):
|
|
175
|
+
#: Which output item the cited text belongs to. The deltas and the closing event are indexed
|
|
176
|
+
#: by this, not by content_index.
|
|
177
|
+
output_index: int = 0
|
|
178
|
+
#: Which content part inside that item, which axio has no index of its own for.
|
|
179
|
+
content_index: int = 0
|
|
180
|
+
annotation: Annotation = field(default_factory=Annotation)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class ContentPartDone(Wire, name="response.content_part.done"):
|
|
185
|
+
output_index: int = 0
|
|
186
|
+
content_index: int = 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True, slots=True)
|
|
190
|
+
class ItemDone(Wire, name="response.output_item.done"):
|
|
191
|
+
output_index: int = 0
|
|
192
|
+
item: OutputItem = field(default_factory=OutputItem)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True, slots=True)
|
|
196
|
+
class ItemAdded(Wire, name="response.output_item.added"):
|
|
197
|
+
output_index: int = 0
|
|
198
|
+
item: OutputItem = field(default_factory=OutputItem)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class ArgumentsDelta(Wire, name="response.function_call_arguments.delta"):
|
|
203
|
+
item_id: str = ""
|
|
204
|
+
output_index: int = 0
|
|
205
|
+
delta: str = ""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True, slots=True)
|
|
209
|
+
class ArgumentsDone(Wire, name="response.function_call_arguments.done"):
|
|
210
|
+
item_id: str = ""
|
|
211
|
+
name: str = ""
|
|
212
|
+
arguments: str = ""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Responses(Reader[StreamEvent]):
|
|
216
|
+
"""Every event the Responses API sends, and what each one becomes.
|
|
217
|
+
|
|
218
|
+
The vocabulary is the published ``ResponseStreamEvent`` union. An event missing from this body
|
|
219
|
+
is one the API added after it was written, not one nobody named. A test reading
|
|
220
|
+
with ``strict=True`` holds that against the schema.
|
|
221
|
+
|
|
222
|
+
One instance reads one response. Its state is the usage, the stop reason and the id map.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(self) -> None:
|
|
226
|
+
self.usage = Usage(0, 0)
|
|
227
|
+
self.stop_reason: StopReason | None = None
|
|
228
|
+
# item_id -> call_id, so a ToolInputDelta carries the id ToolUseStart announced.
|
|
229
|
+
self.call_ids: dict[str, str] = {}
|
|
230
|
+
|
|
231
|
+
@on(TextDeltaEvent)
|
|
232
|
+
def _text(self, wire: TextDeltaEvent) -> Iterator[StreamEvent]:
|
|
233
|
+
yield TextDelta(index=wire.output_index, delta=wire.delta)
|
|
234
|
+
|
|
235
|
+
@on(ReasoningDeltaEvent)
|
|
236
|
+
def _reasoning(self, wire: ReasoningDeltaEvent) -> Iterator[StreamEvent]:
|
|
237
|
+
yield ReasoningDelta(index=wire.output_index, delta=wire.delta)
|
|
238
|
+
|
|
239
|
+
@on(RefusalDeltaEvent)
|
|
240
|
+
def _refusal(self, wire: RefusalDeltaEvent) -> Iterator[StreamEvent]:
|
|
241
|
+
"""A refusal arrives instead of the text, never beside it, so dropping it answers nothing."""
|
|
242
|
+
self.stop_reason = StopReason.refusal
|
|
243
|
+
yield Refusal(index=wire.output_index, text=wire.delta, raw=dict(wire.raw))
|
|
244
|
+
|
|
245
|
+
@on(AnnotationAdded)
|
|
246
|
+
def _annotation(self, wire: AnnotationAdded) -> Iterator[StreamEvent]:
|
|
247
|
+
"""What the text just sent was attributed to."""
|
|
248
|
+
note = wire.annotation
|
|
249
|
+
yield Citation(
|
|
250
|
+
index=wire.output_index,
|
|
251
|
+
cited_text=note.text,
|
|
252
|
+
title=note.title,
|
|
253
|
+
url=note.url or note.source.url or None,
|
|
254
|
+
source_id=note.file_id or note.source.filename or None,
|
|
255
|
+
start=note.start_index,
|
|
256
|
+
end=note.end_index,
|
|
257
|
+
# This API counts characters. Google counts bytes, so the unit travels with them.
|
|
258
|
+
unit="char",
|
|
259
|
+
raw=dict(note.raw),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@on(Created)
|
|
263
|
+
def _created(self, wire: Created) -> Iterator[StreamEvent]:
|
|
264
|
+
"""Which model actually served the turn, which need not be the one asked for."""
|
|
265
|
+
yield IterationStart(iteration=0, id=wire.response.id or None, model=wire.response.model or None)
|
|
266
|
+
|
|
267
|
+
@on(ContentPartDone)
|
|
268
|
+
def _content_part_done(self, wire: ContentPartDone) -> None:
|
|
269
|
+
"""One content part inside an item is complete.
|
|
270
|
+
|
|
271
|
+
Not a BlockEnd: axio indexes a block by output item, and the item's own done event closes
|
|
272
|
+
it. Emitting both closed every block twice, so a consumer that finalises on BlockEnd
|
|
273
|
+
finalised a block it had already finished.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
@on(ItemDone)
|
|
277
|
+
def _item_done(self, wire: ItemDone) -> Iterator[StreamEvent]:
|
|
278
|
+
"""The finished item, which for reasoning is the only place its proof arrives.
|
|
279
|
+
|
|
280
|
+
Sent back on the next request, it lets the model see the reasoning it had already done.
|
|
281
|
+
Without it a turn that reasoned and then called a tool starts the next round blind.
|
|
282
|
+
"""
|
|
283
|
+
if wire.item.type == "reasoning" and wire.item.encrypted_content:
|
|
284
|
+
yield ReasoningSignature(
|
|
285
|
+
index=wire.output_index,
|
|
286
|
+
signature=wire.item.encrypted_content,
|
|
287
|
+
id=wire.item.id,
|
|
288
|
+
provider=PROVIDER,
|
|
289
|
+
)
|
|
290
|
+
elif wire.item.type not in _INTERPRETED:
|
|
291
|
+
# The request says store=False, so this API keeps nothing: every item it produced is
|
|
292
|
+
# expected back on the next one. Watched as a ProviderEvent and never stored, the next
|
|
293
|
+
# request was missing the search the model had just answered from.
|
|
294
|
+
yield ProviderOutput(
|
|
295
|
+
index=wire.output_index,
|
|
296
|
+
provider=PROVIDER,
|
|
297
|
+
kind=wire.item.type,
|
|
298
|
+
data=dict(wire.item.raw),
|
|
299
|
+
id=wire.item.id,
|
|
300
|
+
)
|
|
301
|
+
yield BlockEnd(index=wire.output_index)
|
|
302
|
+
|
|
303
|
+
@on(ItemAdded)
|
|
304
|
+
def _item_added(self, wire: ItemAdded) -> Iterator[StreamEvent]:
|
|
305
|
+
item = wire.item
|
|
306
|
+
if item.type != "function_call":
|
|
307
|
+
logger.info("Output item added: type=%s", item.type)
|
|
308
|
+
return
|
|
309
|
+
if item.id:
|
|
310
|
+
self.call_ids[item.id] = item.call_id
|
|
311
|
+
logger.info("Tool call started: %s (call_id=%s, item_id=%s)", item.name, item.call_id, item.id)
|
|
312
|
+
yield ToolUseStart(index=wire.output_index, tool_use_id=item.call_id, name=item.name, provider=PROVIDER)
|
|
313
|
+
|
|
314
|
+
@on(ArgumentsDelta)
|
|
315
|
+
def _arguments(self, wire: ArgumentsDelta) -> Iterator[StreamEvent]:
|
|
316
|
+
call_id = self._call_id(wire.item_id)
|
|
317
|
+
logger.debug("Tool args delta: call_id=%s, +%d chars", call_id, len(wire.delta))
|
|
318
|
+
yield ToolInputDelta(index=wire.output_index, tool_use_id=call_id, partial_json=wire.delta)
|
|
319
|
+
|
|
320
|
+
# ── what only moves this turn's state ────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
@on(Completed)
|
|
323
|
+
def _completed(self, wire: Completed) -> None:
|
|
324
|
+
response = wire.response
|
|
325
|
+
self._count(response.usage)
|
|
326
|
+
# Not `or "completed"`: a missing or wrongly typed status reads as the empty string here,
|
|
327
|
+
# and coercing it to success made an envelope that never said it finished into a whole
|
|
328
|
+
# answer. Unnamed, it falls to the raise below with the empty string as its name.
|
|
329
|
+
status = response.status
|
|
330
|
+
if self.stop_reason == StopReason.refusal:
|
|
331
|
+
# The status enum has no refusal member, so a declined response still completes.
|
|
332
|
+
pass
|
|
333
|
+
elif status not in STOP_REASONS:
|
|
334
|
+
# Not a finished answer, whatever else it is. Returned as IterationEnd(error) the
|
|
335
|
+
# caller is told only `Transport stopped with: error`, and the status is what they can
|
|
336
|
+
# act on.
|
|
337
|
+
raise StreamError(f"Responses completed with an unknown status: {status!r}")
|
|
338
|
+
else:
|
|
339
|
+
self.stop_reason = STOP_REASONS[status]
|
|
340
|
+
# A finished response still holding a call wants the tool run first. Only a
|
|
341
|
+
# finished one: rewritten, a cancelled or filtered turn passes the dispatch gate.
|
|
342
|
+
if self.stop_reason is StopReason.end_turn and any(
|
|
343
|
+
item.type == "function_call" for item in response.output
|
|
344
|
+
):
|
|
345
|
+
self.stop_reason = StopReason.tool_use
|
|
346
|
+
logger.info(
|
|
347
|
+
"Response completed: status=%s, stop=%s, in=%d, out=%d",
|
|
348
|
+
status,
|
|
349
|
+
self.stop_reason,
|
|
350
|
+
self.usage.input_tokens,
|
|
351
|
+
self.usage.output_tokens,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
@on(Incomplete)
|
|
355
|
+
def _incomplete(self, wire: Incomplete) -> None:
|
|
356
|
+
"""A response the API cut short. Left unread it ends the turn as ``end_turn``, which tells
|
|
357
|
+
the agent a truncated answer is a whole one."""
|
|
358
|
+
self._count(wire.response.usage)
|
|
359
|
+
reason = wire.response.incomplete_details.reason
|
|
360
|
+
self.stop_reason = stop_reason_from(reason, STOP_REASONS, provider="Responses")
|
|
361
|
+
logger.warning("Response incomplete: reason=%s, stop=%s", reason or "unstated", self.stop_reason)
|
|
362
|
+
|
|
363
|
+
@on(ArgumentsDone)
|
|
364
|
+
def _arguments_done(self, wire: ArgumentsDone) -> None:
|
|
365
|
+
# Arguments carry whatever the user typed, and INFO is on in most deployments.
|
|
366
|
+
call_id = self._call_id(wire.item_id)
|
|
367
|
+
logger.info("Tool args complete: %s call_id=%s, %d chars", wire.name or "?", call_id, len(wire.arguments))
|
|
368
|
+
logger.debug("Tool args for call_id=%s: %.200s", call_id, wire.arguments)
|
|
369
|
+
|
|
370
|
+
# ── what ends the turn ───────────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
@on(Failed)
|
|
373
|
+
def _failed(self, wire: Failed) -> None:
|
|
374
|
+
message = wire.response.error.message or "Unknown error"
|
|
375
|
+
logger.error("Response failed: %s", message)
|
|
376
|
+
raise StreamError(f"Responses API error: {message}")
|
|
377
|
+
|
|
378
|
+
@on(StreamFailure)
|
|
379
|
+
def _errored(self, wire: StreamFailure) -> None:
|
|
380
|
+
"""Left unread the turn simply stopped and reported a normal finish."""
|
|
381
|
+
message = wire.message or "Unknown error"
|
|
382
|
+
logger.error("Stream error: %s (code=%s)", message, wire.code or "none")
|
|
383
|
+
raise StreamError(f"Responses API error: {message}")
|
|
384
|
+
|
|
385
|
+
# ── named, and deliberately not read ─────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
@on(
|
|
388
|
+
"response.queued",
|
|
389
|
+
"response.in_progress",
|
|
390
|
+
"response.content_part.added",
|
|
391
|
+
"response.output_text.done",
|
|
392
|
+
"response.refusal.done",
|
|
393
|
+
"response.reasoning_summary_part.added",
|
|
394
|
+
"response.reasoning_summary_part.done",
|
|
395
|
+
"response.reasoning_summary_text.done",
|
|
396
|
+
"response.reasoning_text.done",
|
|
397
|
+
)
|
|
398
|
+
def _expected(self, payload: Payload) -> None:
|
|
399
|
+
"""The envelope opening, and the whole of things already sent delta by delta.
|
|
400
|
+
|
|
401
|
+
Named rather than forwarded because reading them would send the same content twice, and
|
|
402
|
+
because this list is closed. It is the protocol's own bookkeeping, so it does not grow when
|
|
403
|
+
a tool is added.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
# ── everything else, forwarded rather than dropped ───────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
def unmatched(self, name: str, payload: Payload) -> Iterator[StreamEvent]:
|
|
409
|
+
"""Anything this reader does not interpret, passed on under the provider's own name.
|
|
410
|
+
|
|
411
|
+
Almost all of it is the API running a tool on its own side. Each such tool has its own
|
|
412
|
+
event family. That set depends on which tools exist and which the caller declared, not on
|
|
413
|
+
the protocol. Named one by one, the list goes stale the day a tool is added. It also
|
|
414
|
+
reports a new tool as news about the protocol when it is news about the tools.
|
|
415
|
+
|
|
416
|
+
So nothing is listed and nothing is dropped. A consumer that wants the shell commands the
|
|
417
|
+
model ran, or the searches it made, matches on ``kind``. Any other consumer ignores it.
|
|
418
|
+
"""
|
|
419
|
+
yield ProviderEvent(
|
|
420
|
+
provider=PROVIDER,
|
|
421
|
+
kind=name,
|
|
422
|
+
data=dict(payload),
|
|
423
|
+
index=payload.number("output_index", default=-1) if "output_index" in payload else None,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# ── the turn, once it is over ────────────────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
def _call_id(self, item_id: str) -> str:
|
|
429
|
+
"""The call id for this item, or the item id while no mapping for it has arrived."""
|
|
430
|
+
return self.call_ids.get(item_id, item_id)
|
|
431
|
+
|
|
432
|
+
def _count(self, usage: ResponseUsage) -> None:
|
|
433
|
+
"""The token counts, whose slices this API reports inside their totals. Nothing is added."""
|
|
434
|
+
self.usage = Usage.reported(
|
|
435
|
+
input_tokens=usage.input_tokens,
|
|
436
|
+
output_tokens=usage.output_tokens,
|
|
437
|
+
cache_read_tokens=usage.input_tokens_details.cached_tokens,
|
|
438
|
+
cache_write_tokens=usage.input_tokens_details.cache_write_tokens,
|
|
439
|
+
reasoning_tokens=usage.output_tokens_details.reasoning_tokens,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def finished(self) -> IterationEnd:
|
|
443
|
+
"""What the turn added up to. The API sends no event that means this.
|
|
444
|
+
|
|
445
|
+
A stream that ended without one of its terminal events did not finish. The connection was
|
|
446
|
+
cut. Reported as ``end_turn``, a truncated answer is stored and returned as a whole one.
|
|
447
|
+
So it is raised, the way a transport reports any other broken stream.
|
|
448
|
+
"""
|
|
449
|
+
if self.stop_reason is None:
|
|
450
|
+
raise StreamError("Responses stream ended without response.completed, response.incomplete or an error")
|
|
451
|
+
stop = self.stop_reason
|
|
452
|
+
logger.debug(
|
|
453
|
+
"Stream complete: stop_reason=%s, input_tokens=%d, output_tokens=%d",
|
|
454
|
+
stop,
|
|
455
|
+
self.usage.input_tokens,
|
|
456
|
+
self.usage.output_tokens,
|
|
457
|
+
)
|
|
458
|
+
if stop is StopReason.error:
|
|
459
|
+
raise StreamError("Responses stopped with an error the reader did not name")
|
|
460
|
+
return IterationEnd(iteration=0, stop_reason=stop, usage=self.usage)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Building a Responses request: instructions, input items, and tool declarations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Final
|
|
9
|
+
|
|
10
|
+
from axio.blocks import (
|
|
11
|
+
AudioBlock,
|
|
12
|
+
ImageBlock,
|
|
13
|
+
ProviderBlock,
|
|
14
|
+
ReasoningBlock,
|
|
15
|
+
TextBlock,
|
|
16
|
+
ToolResultBlock,
|
|
17
|
+
ToolUseBlock,
|
|
18
|
+
VideoBlock,
|
|
19
|
+
proof,
|
|
20
|
+
replayable,
|
|
21
|
+
)
|
|
22
|
+
from axio.messages import Message
|
|
23
|
+
from axio.schema import strip_title
|
|
24
|
+
from axio.tool import Tool
|
|
25
|
+
from axio.types import StopReason
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
#: What this protocol is called wherever its name is written: on the proofs it issues, on the
|
|
30
|
+
#: items it expects back, and on the events it forwards. The Codex transport speaks this same
|
|
31
|
+
#: protocol, so its turns say "openai" too rather than naming a fifth provider.
|
|
32
|
+
PROVIDER: Final = "openai"
|
|
33
|
+
|
|
34
|
+
STOP_REASONS: dict[str, StopReason] = {
|
|
35
|
+
"completed": StopReason.end_turn,
|
|
36
|
+
"end_turn": StopReason.end_turn,
|
|
37
|
+
"stop": StopReason.end_turn,
|
|
38
|
+
"max_output_tokens": StopReason.max_tokens,
|
|
39
|
+
"length": StopReason.max_tokens,
|
|
40
|
+
"cancelled": StopReason.cancelled,
|
|
41
|
+
"content_filter": StopReason.refusal,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def convert_tools(tools: list[Tool[Any]]) -> list[dict[str, Any]]:
|
|
46
|
+
"""Convert axio Tool list to Responses API function tool dicts."""
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
"type": "function",
|
|
50
|
+
"name": tool.name,
|
|
51
|
+
"description": tool.description,
|
|
52
|
+
"parameters": strip_title(tool.input_schema),
|
|
53
|
+
}
|
|
54
|
+
for tool in tools
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def tool_output(content: str | list[TextBlock | ImageBlock | AudioBlock | VideoBlock]) -> str | list[dict[str, Any]]:
|
|
59
|
+
"""A tool result as the output this API takes.
|
|
60
|
+
|
|
61
|
+
``json.dumps`` on the blocks raises. They are slotted dataclasses, not JSON. A tool that
|
|
62
|
+
returned anything but a string crashed the request before it was sent.
|
|
63
|
+
|
|
64
|
+
The API takes a string, or a list of ``input_text``, ``input_image`` and ``input_file`` parts.
|
|
65
|
+
Text and images travel as themselves. Audio and video have no part of their own here. They
|
|
66
|
+
are named in text instead. The model is told what the tool produced, rather than handed a turn
|
|
67
|
+
with a gap in it.
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(content, str):
|
|
70
|
+
return content
|
|
71
|
+
parts: list[dict[str, Any]] = []
|
|
72
|
+
for block in content:
|
|
73
|
+
if isinstance(block, TextBlock):
|
|
74
|
+
parts.append({"type": "input_text", "text": block.text})
|
|
75
|
+
elif isinstance(block, ImageBlock):
|
|
76
|
+
encoded = base64.b64encode(block.data).decode("ascii")
|
|
77
|
+
parts.append({"type": "input_image", "image_url": f"data:{block.media_type};base64,{encoded}"})
|
|
78
|
+
elif isinstance(block, (AudioBlock, VideoBlock)):
|
|
79
|
+
parts.append({"type": "input_text", "text": f"[{block.media_type}, which this API takes no part for]"})
|
|
80
|
+
# An empty list would say the tool returned nothing at all.
|
|
81
|
+
return parts or ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _flush_text(items: list[dict[str, Any]], parts: list[dict[str, Any]]) -> None:
|
|
85
|
+
"""Emit the assistant text collected so far, keeping it in the order the turn stored it.
|
|
86
|
+
|
|
87
|
+
The text goes back as a plain string. An ``output_text`` part belongs to an output message,
|
|
88
|
+
which the API requires to carry ``id``, ``type`` and ``status`` as well; sent without them it
|
|
89
|
+
matches no input item the API defines.
|
|
90
|
+
|
|
91
|
+
Inserted at an index counted back from the tail instead, the text moved behind a call it
|
|
92
|
+
introduced whenever reasoning was stored after that call.
|
|
93
|
+
"""
|
|
94
|
+
if parts:
|
|
95
|
+
items.append({"role": "assistant", "content": "".join(part["text"] for part in parts)})
|
|
96
|
+
parts.clear()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def convert_messages(messages: list[Message], system: str) -> tuple[str, list[dict[str, Any]]]:
|
|
100
|
+
"""Convert axio Message list to Responses API input array.
|
|
101
|
+
|
|
102
|
+
Returns (instructions, input_items).
|
|
103
|
+
"""
|
|
104
|
+
items: list[dict[str, Any]] = []
|
|
105
|
+
|
|
106
|
+
for msg in messages:
|
|
107
|
+
if msg.role == "user":
|
|
108
|
+
# Every result in the turn, whatever else it carries beside them.
|
|
109
|
+
tool_results = [b for b in msg.content if isinstance(b, ToolResultBlock)]
|
|
110
|
+
if tool_results:
|
|
111
|
+
for tr in tool_results:
|
|
112
|
+
items.append(
|
|
113
|
+
{
|
|
114
|
+
"type": "function_call_output",
|
|
115
|
+
"call_id": tr.tool_use_id,
|
|
116
|
+
"output": tool_output(tr.content),
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
rest = [b for b in msg.content if not isinstance(b, ToolResultBlock)]
|
|
120
|
+
if rest:
|
|
121
|
+
content_parts: list[dict[str, Any]] = []
|
|
122
|
+
for b in rest:
|
|
123
|
+
if isinstance(b, TextBlock):
|
|
124
|
+
content_parts.append({"type": "input_text", "text": b.text})
|
|
125
|
+
elif isinstance(b, ImageBlock):
|
|
126
|
+
encoded = base64.b64encode(b.data).decode("ascii")
|
|
127
|
+
data_uri = f"data:{b.media_type};base64,{encoded}"
|
|
128
|
+
content_parts.append({"type": "input_image", "image_url": data_uri})
|
|
129
|
+
if content_parts:
|
|
130
|
+
items.append({"role": "user", "content": content_parts})
|
|
131
|
+
|
|
132
|
+
elif msg.role == "system":
|
|
133
|
+
# A system message inside the history, not the prompt carried in ``instructions``.
|
|
134
|
+
text = "".join(b.text for b in msg.content if isinstance(b, TextBlock))
|
|
135
|
+
if text:
|
|
136
|
+
items.append({"role": "system", "content": [{"type": "input_text", "text": text}]})
|
|
137
|
+
|
|
138
|
+
elif msg.role == "assistant":
|
|
139
|
+
content_parts_a: list[dict[str, Any]] = []
|
|
140
|
+
for b in msg.content:
|
|
141
|
+
if isinstance(b, ReasoningBlock):
|
|
142
|
+
# `id` and `summary` are required beside the proof. The flush follows that test,
|
|
143
|
+
# or a dropped block splits one run of text in two. `proof` leaves out what
|
|
144
|
+
# another provider issued: sent here it is not encrypted content at all.
|
|
145
|
+
if b.id and (encrypted := proof(b, PROVIDER)):
|
|
146
|
+
_flush_text(items, content_parts_a)
|
|
147
|
+
items.append(
|
|
148
|
+
{
|
|
149
|
+
"type": "reasoning",
|
|
150
|
+
"id": b.id,
|
|
151
|
+
"encrypted_content": encrypted,
|
|
152
|
+
"summary": [],
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
logger.debug("Dropping a reasoning block with no encrypted content to replay")
|
|
157
|
+
elif isinstance(b, ProviderBlock):
|
|
158
|
+
# Back exactly as it arrived. This API keeps no copy of the turn, so an item
|
|
159
|
+
# from a tool it ran itself is only in the request if we put it there.
|
|
160
|
+
if replayable(b, PROVIDER):
|
|
161
|
+
_flush_text(items, content_parts_a)
|
|
162
|
+
items.append(dict(b.data))
|
|
163
|
+
elif isinstance(b, TextBlock):
|
|
164
|
+
content_parts_a.append({"type": "output_text", "text": b.text})
|
|
165
|
+
elif isinstance(b, ToolUseBlock):
|
|
166
|
+
_flush_text(items, content_parts_a)
|
|
167
|
+
items.append(
|
|
168
|
+
{
|
|
169
|
+
"type": "function_call",
|
|
170
|
+
"call_id": b.id,
|
|
171
|
+
"name": b.name,
|
|
172
|
+
"arguments": json.dumps(b.input),
|
|
173
|
+
"status": "completed",
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
_flush_text(items, content_parts_a)
|
|
177
|
+
|
|
178
|
+
# A call the history has no result for. The API pairs the two by call_id, and the model reads
|
|
179
|
+
# them in order, so the stand-in goes where the result would have been.
|
|
180
|
+
answered = {i["call_id"] for i in items if i.get("type") == "function_call_output"}
|
|
181
|
+
placed: list[dict[str, Any]] = []
|
|
182
|
+
for item in items:
|
|
183
|
+
placed.append(item)
|
|
184
|
+
if item.get("type") != "function_call" or item.get("call_id") in answered:
|
|
185
|
+
continue
|
|
186
|
+
call_id = item.get("call_id", "")
|
|
187
|
+
# As we go: a call_id appearing twice took a stand-in each time.
|
|
188
|
+
answered.add(call_id)
|
|
189
|
+
logger.warning("Synthesizing placeholder output for orphan function_call: call_id=%s", call_id)
|
|
190
|
+
placed.append(
|
|
191
|
+
{
|
|
192
|
+
"type": "function_call_output",
|
|
193
|
+
"call_id": call_id,
|
|
194
|
+
"output": "[Tool was not executed - context was interrupted or compacted]",
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
items = placed
|
|
198
|
+
|
|
199
|
+
# Once, over the whole array. The API refuses a reasoning item with nothing after it, and only
|
|
200
|
+
# the last item has nothing after it. Trimmed per turn, reasoning that the next turn's own
|
|
201
|
+
# items follow was dropped from the middle of the conversation.
|
|
202
|
+
while items and items[-1].get("type") == "reasoning":
|
|
203
|
+
logger.debug("Dropping a trailing reasoning item, which has no following item")
|
|
204
|
+
items.pop()
|
|
205
|
+
|
|
206
|
+
return system, items
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: axio-responses
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: The OpenAI Responses API as axio speaks it: request items in, StreamEvents out
|
|
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: agent,llm,openai,responses,streaming
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: axio
|
|
12
|
+
Requires-Dist: axio-sse
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# axio-responses
|
|
16
|
+
|
|
17
|
+
[](https://pypi.org/project/axio-responses/)
|
|
18
|
+
[](https://pypi.org/project/axio-responses/)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
|
|
21
|
+
The OpenAI Responses API as [axio](https://github.com/mosquito/axio-agent) speaks it: request items
|
|
22
|
+
in, `StreamEvent`s out.
|
|
23
|
+
|
|
24
|
+
Both halves live here rather than in a transport because two transports speak this API — the public
|
|
25
|
+
`/v1/responses` endpoint and the ChatGPT backend Codex uses. It knows nothing about HTTP and opens
|
|
26
|
+
no connection.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install axio-responses
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### Building the request
|
|
37
|
+
|
|
38
|
+
<!-- name: test_readme_building_a_request -->
|
|
39
|
+
```python
|
|
40
|
+
from axio.blocks import TextBlock
|
|
41
|
+
from axio.messages import Message
|
|
42
|
+
from axio_responses import convert_messages, convert_tools
|
|
43
|
+
|
|
44
|
+
messages, system, tools = [Message(role="user", content=[TextBlock(text="hi")])], "be brief", []
|
|
45
|
+
|
|
46
|
+
instructions, items = convert_messages(messages, system)
|
|
47
|
+
payload = {
|
|
48
|
+
"model": "gpt-5.6",
|
|
49
|
+
"instructions": instructions,
|
|
50
|
+
"input": items,
|
|
51
|
+
"stream": True,
|
|
52
|
+
"tools": convert_tools(tools),
|
|
53
|
+
}
|
|
54
|
+
assert payload["instructions"] == "be brief"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`convert_messages` returns the system prompt separately, because this API takes it as
|
|
58
|
+
`instructions` rather than as a message. Tool calls and their outputs become `function_call` and
|
|
59
|
+
`function_call_output` items beside the messages, not blocks inside them.
|
|
60
|
+
|
|
61
|
+
### Reading the stream
|
|
62
|
+
|
|
63
|
+
`Responses` is an `axio_sse.Reader`: one `@on(...)` method per event, dispatching on the payload's
|
|
64
|
+
own `type`. Its class body names only the events it interprets. The API publishes one event family
|
|
65
|
+
per tool it can run, so that set grows with the tools and not with the protocol; everything else is
|
|
66
|
+
forwarded through `unmatched()` rather than dropped.
|
|
67
|
+
|
|
68
|
+
<!-- name: test_readme_reading_the_stream -->
|
|
69
|
+
```python
|
|
70
|
+
from collections.abc import AsyncIterator
|
|
71
|
+
|
|
72
|
+
import aiohttp
|
|
73
|
+
from axio.events import StreamEvent
|
|
74
|
+
from axio_responses import Responses
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def stream(resp: aiohttp.ClientResponse) -> AsyncIterator[StreamEvent]:
|
|
78
|
+
turn = Responses()
|
|
79
|
+
async for made in turn.over(resp.content.iter_any(), until="[DONE]"):
|
|
80
|
+
yield made
|
|
81
|
+
yield turn.finished()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Events axio has no type for — the API's own hosted tools, its audio, its bookkeeping — travel as
|
|
85
|
+
`ProviderEvent` under the provider's own name rather than being dropped.
|
|
86
|
+
|
|
87
|
+
### Holding it against the schema
|
|
88
|
+
|
|
89
|
+
<!-- name: test_readme_names_are_published -->
|
|
90
|
+
```python
|
|
91
|
+
from axio_responses import Responses
|
|
92
|
+
|
|
93
|
+
PUBLISHED_EVENTS = {"response.output_text.delta", "response.completed", "response.refusal.delta"}
|
|
94
|
+
|
|
95
|
+
# Every name the reader claims is one the schema publishes. A typo is a handler that never runs.
|
|
96
|
+
assert Responses.names() >= PUBLISHED_EVENTS
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`names()` answers what the reader claims, so a test can hold it against the union OpenAI publishes.
|
|
100
|
+
The check is `<=`, not `==`: the reader deliberately names fewer events than the API sends. Reading
|
|
101
|
+
with `strict=True` raises `UnknownEvent` on a name it does not claim, which is how a test fails on
|
|
102
|
+
the day OpenAI adds one.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
axio_responses/__init__.py,sha256=ZmgtSVdrdthL792dhFz8VsCXBXh5qlhdU3jT5X9KRF8,1491
|
|
2
|
+
axio_responses/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
axio_responses/reader.py,sha256=md_7t4VLcQdW0GnWpLEu9BYfqRCK2zMuvMqa-sIv5CY,19087
|
|
4
|
+
axio_responses/request.py,sha256=Mjxt1gL_XC0siOlPphwVRJXvJjUHX5PotQV6xUBmUdI,9038
|
|
5
|
+
axio_responses-0.11.0.dist-info/METADATA,sha256=ABzRft35cLcd54rLuTNj0UkfgShje3sRtZGepRTvUp0,3713
|
|
6
|
+
axio_responses-0.11.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
axio_responses-0.11.0.dist-info/RECORD,,
|