Coverage for src / lexigram / contracts / ai / relay / dto / openai_responses.py: 36%
205 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
1"""OpenAI Responses wire DTO family."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from typing import Any
8from lexigram.contracts.ai.relay.dto.common import require_field
10__all__ = [
11 "ResponsesEvent",
12 "ResponsesIncompleteDetails",
13 "ResponsesItem",
14 "ResponsesRequest",
15 "ResponsesResponse",
16 "ResponsesUsage",
17]
20@dataclass(frozen=True)
21class ResponsesItem:
22 """An item in a Responses request ``input`` or response ``output``.
24 Attributes:
25 type: ``message``, ``function_call``, ``function_call_output``,
26 ``reasoning``, ``web_search_call``.
27 role: ``user`` / ``assistant`` for message items.
28 content: Message content list, or ``None``.
29 id: Item id, or ``None``.
30 call_id: Function call id, or ``None``.
31 name: Function name, or ``None``.
32 arguments: Function call arguments string, or ``None``.
33 output: Function call output string, or ``None``.
34 summary: Reasoning summary for ``reasoning`` items, or ``None``.
35 status: Output item status (``completed`` / ``incomplete``).
36 quality: Output item quality (relaykit always serializes ``""``).
37 size: Output item size (relaykit always serializes ``""``).
38 passthrough: Unknown fields preserved verbatim.
39 """
41 type: str | None = None
42 role: str | None = None
43 content: str | list[dict[str, Any]] | None = None
44 id: str | None = None
45 call_id: str | None = None
46 name: str | None = None
47 arguments: str | None = None
48 output: str | None = None
49 summary: list[dict[str, Any]] | None = None
50 status: str | None = None
51 quality: str | None = None
52 size: str | None = None
53 passthrough: dict[str, Any] = field(default_factory=dict)
55 def to_dict(self) -> dict[str, Any]:
56 """Serialize to wire dict.
58 Output items (those carrying a ``status``) always serialize
59 ``content`` (relaykit uses no ``omitempty`` on it, so function
60 calls carry an explicit ``null``) plus the ``status``,
61 ``quality``, and ``size`` fields relaykit Go structs always
62 render. Input request items never render a ``content`` key for
63 function calls.
64 """
65 data: dict[str, Any] = {**self.passthrough}
66 if self.type is not None:
67 data["type"] = self.type
68 if self.role is not None:
69 data["role"] = self.role
70 if (
71 self.type == "function_call" and self.status is not None
72 ) or self.content is not None:
73 data["content"] = self.content
74 if self.status is not None:
75 data["status"] = self.status
76 if self.quality is not None:
77 data["quality"] = self.quality
78 if self.size is not None:
79 data["size"] = self.size
80 if self.id is not None:
81 data["id"] = self.id
82 if self.call_id is not None:
83 data["call_id"] = self.call_id
84 if self.name is not None:
85 data["name"] = self.name
86 if self.arguments is not None:
87 data["arguments"] = self.arguments
88 if self.output is not None:
89 data["output"] = self.output
90 if self.summary is not None:
91 data["summary"] = self.summary
92 return data
94 @classmethod
95 def from_dict(cls, data: dict[str, Any]) -> ResponsesItem:
96 """Build an item from a wire dict, capturing unknown keys."""
97 known = {
98 "type",
99 "role",
100 "content",
101 "id",
102 "call_id",
103 "name",
104 "arguments",
105 "output",
106 "summary",
107 "status",
108 "quality",
109 "size",
110 }
111 return cls(
112 type=data.get("type", "message"),
113 role=data.get("role"),
114 content=data.get("content"),
115 id=data.get("id"),
116 call_id=data.get("call_id"),
117 name=data.get("name"),
118 arguments=data.get("arguments"),
119 output=data.get("output"),
120 summary=data.get("summary"),
121 status=data.get("status"),
122 quality=data.get("quality"),
123 size=data.get("size"),
124 passthrough={k: v for k, v in data.items() if k not in known},
125 )
128@dataclass(frozen=True)
129class ResponsesRequest:
130 """OpenAI Responses request body.
132 Attributes:
133 model: Model name.
134 input: List of items, or a plain string.
135 instructions: System instructions, or ``None``.
136 tools: Tool definitions, or ``None``.
137 temperature: Sampling temperature, or ``None``.
138 max_output_tokens: Max output tokens, or ``None``.
139 stream: Whether the caller wants a stream.
140 include: Extra top-level fields to include, or ``None``.
141 parallel_tool_calls: Parallel tool-call flag, or ``None``.
142 reasoning: Reasoning config (e.g. ``{"effort": ...}``), or ``None``.
143 text: Response-format config, or ``None``.
144 service_tier: Service tier, or ``None``.
145 passthrough: Unknown fields preserved verbatim.
146 """
148 model: str
149 input: list[ResponsesItem] | str
150 instructions: str | None = None
151 tools: list[dict[str, Any]] | None = None
152 temperature: float | None = None
153 max_output_tokens: int | None = None
154 stream: bool = False
155 include: list[str] | None = None
156 parallel_tool_calls: bool | None = None
157 reasoning: dict[str, Any] | None = None
158 text: dict[str, Any] | None = None
159 service_tier: str | None = None
160 tool_choice: Any | None = None
161 passthrough: dict[str, Any] = field(default_factory=dict)
163 def to_dict(self) -> dict[str, Any]:
164 """Serialize to wire dict, omitting ``None`` optional fields."""
165 data: dict[str, Any] = {**self.passthrough, "model": self.model}
166 if isinstance(self.input, str):
167 data["input"] = self.input
168 else:
169 data["input"] = [i.to_dict() for i in self.input]
170 if self.instructions is not None:
171 data["instructions"] = self.instructions
172 if self.tools is not None:
173 data["tools"] = self.tools
174 if self.temperature is not None:
175 data["temperature"] = self.temperature
176 if self.max_output_tokens is not None:
177 data["max_output_tokens"] = self.max_output_tokens
178 data["stream"] = self.stream
179 if self.include is not None:
180 data["include"] = self.include
181 if self.parallel_tool_calls is not None:
182 data["parallel_tool_calls"] = self.parallel_tool_calls
183 if self.reasoning is not None:
184 data["reasoning"] = self.reasoning
185 if self.text is not None:
186 data["text"] = self.text
187 if self.service_tier is not None:
188 data["service_tier"] = self.service_tier
189 if self.tool_choice is not None:
190 data["tool_choice"] = self.tool_choice
191 return data
193 @classmethod
194 def from_dict(cls, data: dict[str, Any]) -> ResponsesRequest:
195 """Build a request from a wire dict, capturing unknown keys.
197 Raises:
198 RelayError: With code ``malformed_payload`` when ``model``
199 is absent.
200 """
201 known = {
202 "model",
203 "input",
204 "instructions",
205 "tools",
206 "temperature",
207 "max_output_tokens",
208 "stream",
209 "include",
210 "parallel_tool_calls",
211 "reasoning",
212 "text",
213 "service_tier",
214 "tool_choice",
215 }
216 raw_input = data.get("input", [])
217 return cls(
218 model=require_field(data, "model"),
219 input=(
220 raw_input
221 if isinstance(raw_input, str)
222 else [ResponsesItem.from_dict(i) for i in raw_input]
223 ),
224 instructions=data.get("instructions"),
225 tools=data.get("tools"),
226 temperature=data.get("temperature"),
227 max_output_tokens=data.get("max_output_tokens"),
228 stream=bool(data.get("stream", False)),
229 include=data.get("include"),
230 parallel_tool_calls=data.get("parallel_tool_calls"),
231 reasoning=data.get("reasoning"),
232 text=data.get("text"),
233 service_tier=data.get("service_tier"),
234 tool_choice=data.get("tool_choice"),
235 passthrough={k: v for k, v in data.items() if k not in known},
236 )
239@dataclass(frozen=True)
240class ResponsesUsage:
241 """OpenAI Responses usage accounting.
243 Attributes:
244 prompt_tokens: Chat-style prompt token count.
245 completion_tokens: Chat-style completion token count.
246 total_tokens: Explicit totals (can differ from prompt + completion).
247 prompt_tokens_details: Cache details (relaykit serializes a
248 zero-value prompt_tokens_details dict even when empty).
249 completion_tokens_details: Reasoning/details count (relaykit
250 serializes it even when empty).
251 input_tokens: Source input count carried into chat emission.
252 input_tokens_details: Raw input token details per ``input_tokens``.
253 output_tokens: Source output count carried into chat emails.
254 output_tokens_details: Legacy capture of the raw output details
255 (never serialized; relaykit emits ``completion_tokens_details``).
256 passthrough: Unknown fields preserved verbatim.
257 """
259 prompt_tokens: int = 0
260 completion_tokens: int = 0
261 total_tokens: int = 0
262 prompt_tokens_details: dict[str, Any] | None = None
263 completion_tokens_details: dict[str, Any] | None = None
264 input_tokens: int = 0
265 input_tokens_details: dict[str, Any] | None = None
266 output_tokens: int = 0
267 output_tokens_details: dict[str, Any] | None = None
268 passthrough: dict[str, Any] = field(default_factory=dict)
270 def to_dict(self) -> dict[str, Any]:
271 """Serialize to wire dict, mirroring relaykit's always-on usage."""
272 data: dict[str, Any] = {
273 **self.passthrough,
274 "prompt_tokens": self.prompt_tokens,
275 "completion_tokens": self.completion_tokens,
276 "total_tokens": self.total_tokens
277 or (self.input_tokens + self.output_tokens),
278 "prompt_tokens_details": self.prompt_tokens_details or {"cached_tokens": 0},
279 "completion_tokens_details": self.completion_tokens_details
280 or {"reasoning_tokens": 0},
281 "input_tokens": self.input_tokens,
282 "output_tokens": self.output_tokens,
283 }
284 if self.input_tokens_details is not None:
285 data["input_tokens_details"] = self.input_tokens_details
286 return data
288 @classmethod
289 def from_dict(cls, data: dict[str, Any]) -> ResponsesUsage:
290 """Build usage from a wire dict, capturing unknown keys."""
291 known = {
292 "input_tokens",
293 "input_tokens_details",
294 "output_tokens",
295 "output_tokens_details",
296 "total_tokens",
297 "prompt_tokens_details",
298 "completion_tokens_details",
299 "prompt_tokens",
300 "completion_tokens",
301 }
302 return cls(
303 prompt_tokens=data.get("prompt_tokens", 0),
304 completion_tokens=data.get("completion_tokens", 0),
305 total_tokens=data.get("total_tokens", 0),
306 prompt_tokens_details=data.get("prompt_tokens_details"),
307 completion_tokens_details=data.get("completion_tokens_details"),
308 input_tokens=data.get("input_tokens", 0),
309 input_tokens_details=data.get("input_tokens_details"),
310 output_tokens=data.get("output_tokens", 0),
311 output_tokens_details=data.get("output_tokens_details"),
312 passthrough={k: v for k, v in data.items() if k not in known},
313 )
316@dataclass(frozen=True)
317class ResponsesIncompleteDetails:
318 """Why a Responses response is incomplete.
320 Attributes:
321 reason: ``max_output_tokens``, ``content_filter``, etc.
322 passthrough: Unknown fields preserved verbatim.
323 """
325 reason: str | None = None
326 passthrough: dict[str, Any] = field(default_factory=dict)
328 def to_dict(self) -> dict[str, Any]:
329 """Serialize to wire dict, omitting ``None`` optional fields."""
330 data: dict[str, Any] = {**self.passthrough}
331 if self.reason is not None:
332 data["reason"] = self.reason
333 return data
335 @classmethod
336 def from_dict(cls, data: dict[str, Any]) -> ResponsesIncompleteDetails:
337 """Build details from a wire dict, capturing unknown keys."""
338 known = {"reason"}
339 return cls(
340 reason=data.get("reason"),
341 passthrough={k: v for k, v in data.items() if k not in known},
342 )
345@dataclass(frozen=True)
346class ResponsesResponse:
347 """OpenAI Responses non-streamed response body.
349 Attributes:
350 id: Response id.
351 model: Model name.
352 output: Output items (messages, function calls, reasoning).
353 object: Object type (``response``).
354 created_at: Unix timestamp.
355 status: ``completed``, ``in_progress``, ``incomplete``, ``failed``.
356 incomplete_details: Why the response is incomplete, or ``None``.
357 error: Error object for failed responses, or ``None``.
358 usage: Typed usage, or ``None``.
359 passthrough: Unknown fields preserved verbatim.
360 """
362 id: str
363 model: str
364 output: list[ResponsesItem]
365 object: str = "response"
366 created_at: int = 0
367 status: str | None = None
368 incomplete_details: ResponsesIncompleteDetails | None = None
369 error: dict[str, Any] | None = None
370 usage: ResponsesUsage | None = None
371 passthrough: dict[str, Any] = field(default_factory=dict)
373 def to_dict(self) -> dict[str, Any]:
374 """Serialize to wire dict.
376 Relaykit's Go response structs always serialize the optional
377 request-style fields (``instructions``, ``temperature``, ...) as
378 explicit nulls/zeros, so this mirrors that shape.
379 """
380 data: dict[str, Any] = {
381 **self.passthrough,
382 "id": self.id,
383 "object": self.object,
384 "created_at": self.created_at,
385 "status": self.status or "completed",
386 "instructions": None,
387 "max_output_tokens": 0,
388 "model": self.model,
389 "output": [i.to_dict() for i in self.output],
390 "parallel_tool_calls": False,
391 "previous_response_id": None,
392 "reasoning": None,
393 "store": False,
394 "temperature": 0,
395 "tool_choice": None,
396 "tools": None,
397 "top_p": 0,
398 "truncation": None,
399 }
400 if self.incomplete_details is not None:
401 data["incomplete_details"] = self.incomplete_details.to_dict()
402 if self.error is not None:
403 data["error"] = self.error
404 if self.usage is not None:
405 data["usage"] = self.usage.to_dict()
406 data["user"] = None
407 data["metadata"] = None
408 return data
410 @classmethod
411 def from_dict(cls, data: dict[str, Any]) -> ResponsesResponse:
412 """Build a response from a wire dict, capturing unknown keys.
414 Raises:
415 RelayError: With code ``malformed_payload`` when ``id`` or
416 ``model`` is absent.
417 """
418 known = {
419 "id",
420 "object",
421 "created_at",
422 "model",
423 "output",
424 "status",
425 "incomplete_details",
426 "error",
427 "usage",
428 }
429 incomplete = data.get("incomplete_details")
430 usage = data.get("usage")
431 return cls(
432 id=require_field(data, "id"),
433 object=data.get("object", "response"),
434 created_at=data.get("created_at", 0),
435 model=require_field(data, "model"),
436 output=[ResponsesItem.from_dict(i) for i in data.get("output", [])],
437 status=data.get("status"),
438 incomplete_details=(
439 ResponsesIncompleteDetails.from_dict(incomplete)
440 if isinstance(incomplete, dict)
441 else None
442 ),
443 error=data.get("error"),
444 usage=ResponsesUsage.from_dict(usage) if isinstance(usage, dict) else None,
445 passthrough={k: v for k, v in data.items() if k not in known},
446 )
449@dataclass(frozen=True)
450class ResponsesEvent:
451 """One SSE event in the Responses stream lifecycle.
453 The ``type`` field is the discriminator (``response.created``,
454 ``response.output_item.added``, ``response.content_part.added``,
455 ``response.output_text.delta``, ``response.output_text.done``,
456 ``response.content_part.done``, ``response.output_item.done``,
457 ``response.function_call_arguments.delta``,
458 ``response.function_call_arguments.done``,
459 ``response.reasoning_summary_text.delta``,
460 ``response.reasoning_summary_text.done``, ``response.completed``,
461 ``response.incomplete``, ``response.failed``, ``response.error``).
463 Attributes:
464 type: Event type discriminator.
465 sequence_number: Monotonic event sequence number.
466 response: Snapshot on ``response.*`` lifecycle events, or ``None``.
467 item: Item on ``output_item.*`` events, or ``None``.
468 item_id: Id of the item an event describes, or ``None``.
469 output_index: Output item index, or ``None``.
470 content_index: Content part index, or ``None``.
471 part: Raw content part on ``content_part.*`` events, or ``None``.
472 delta: Text/arguments delta, or ``None``.
473 error: Raw error payload, or ``None``.
474 passthrough: Unknown fields preserved verbatim.
475 """
477 type: str
478 sequence_number: int = 0
479 response: ResponsesResponse | None = None
480 item: ResponsesItem | None = None
481 item_id: str | None = None
482 output_index: int | None = None
483 content_index: int | None = None
484 part: dict[str, Any] | None = None
485 delta: str | None = None
486 error: dict[str, Any] | None = None
487 passthrough: dict[str, Any] = field(default_factory=dict)
489 def to_dict(self) -> dict[str, Any]:
490 """Serialize to wire dict as the Responses SSE envelope.
492 Relaykit frames each stream event as ``{"Type": <type>, "Payload":
493 <fields>}`` — the ``Type`` is the SSE ``event`` name and ``Payload``
494 is the JSON body (which repeats the ``type`` discriminator).
495 """
496 payload: dict[str, Any] = {**self.passthrough, "type": self.type}
497 if self.sequence_number:
498 payload["sequence_number"] = self.sequence_number
499 if self.response is not None:
500 payload["response"] = self.response.to_dict()
501 if self.item is not None:
502 payload["item"] = self.item.to_dict()
503 if self.item_id is not None:
504 payload["item_id"] = self.item_id
505 if self.output_index is not None:
506 payload["output_index"] = self.output_index
507 if self.content_index is not None:
508 payload["content_index"] = self.content_index
509 if self.part is not None:
510 payload["part"] = self.part
511 if self.delta is not None:
512 payload["delta"] = self.delta
513 if self.error is not None:
514 payload["error"] = self.error
515 return {"Type": self.type, "Payload": payload}
517 @classmethod
518 def from_dict(cls, data: dict[str, Any]) -> ResponsesEvent:
519 """Build an event from a wire dict, capturing unknown keys.
521 Accepts both the SSE envelope (``Payload``) and the bare payload.
522 """
523 source = data.get("Payload")
524 if isinstance(source, dict):
525 data = source
526 known = {
527 "type",
528 "sequence_number",
529 "response",
530 "item",
531 "item_id",
532 "output_index",
533 "content_index",
534 "part",
535 "delta",
536 "error",
537 }
538 response = data.get("response")
539 item = data.get("item")
540 return cls(
541 type=data.get("type", "response.in_progress"),
542 sequence_number=data.get("sequence_number", 0),
543 response=(
544 ResponsesResponse.from_dict(response)
545 if isinstance(response, dict)
546 else None
547 ),
548 item=ResponsesItem.from_dict(item) if isinstance(item, dict) else None,
549 item_id=data.get("item_id"),
550 output_index=data.get("output_index"),
551 content_index=data.get("content_index"),
552 part=data.get("part"),
553 delta=data.get("delta"),
554 error=data.get("error"),
555 passthrough={k: v for k, v in data.items() if k not in known},
556 )