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.
@@ -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
+ ]
File without changes
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/axio-responses)](https://pypi.org/project/axio-responses/)
18
+ [![Python](https://img.shields.io/pypi/pyversions/axio-responses)](https://pypi.org/project/axio-responses/)
19
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any