Coverage for src / lexigram / ai / relay / mappers / openai_responses.py: 91%

475 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-08 23:08 +0800

1"""OpenAI Responses request and response mapper. 

2 

3Converts the OpenAI Responses wire DTOs 

4(:class:`ResponsesRequest` / :class:`ResponsesResponse`) into the 

5canonical relay IR and back. Stream conversion is handled by the shared 

6stream lifecycle task and reports ``unsupported_feature`` until then. 

7""" 

8 

9from __future__ import annotations 

10 

11from typing import Any, cast 

12 

13from lexigram.ai.relay.context import ConversionContext 

14from lexigram.ai.relay.errors import translate, unsupported_feature, unsupported_format 

15from lexigram.ai.relay.finish_reasons import ( 

16 responses_incomplete_from_finish, 

17 responses_status_from_finish, 

18) 

19from lexigram.ai.relay.mappers.base import new_uuid, record_loss 

20from lexigram.contracts.ai.agents import ToolDefinition 

21from lexigram.contracts.ai.exceptions import RelayError 

22from lexigram.contracts.ai.llm import ChatMessage, FunctionCall, ToolCall 

23from lexigram.contracts.ai.multimodal import ImageBase64Part, ImageUrlPart, TextPart 

24from lexigram.contracts.ai.relay.dto import ( 

25 ResponsesIncompleteDetails, 

26 ResponsesItem, 

27 ResponsesRequest, 

28 ResponsesResponse, 

29 ResponsesUsage, 

30) 

31from lexigram.contracts.ai.relay.ir import ( 

32 RelayRequest, 

33 RelayResponse, 

34 StreamDelta, 

35 StreamState, 

36) 

37from lexigram.contracts.ai.relay.types import RelayFormat, RelayUsage 

38from lexigram.contracts.ai.thinking import ThinkingConfig, ThinkingResult 

39from lexigram.contracts.core.result import Err, Ok, Result 

40from lexigram.serialization import dumps_str, loads_str 

41 

42__all__ = ["OpenAIResponsesMapper"] 

43 

44_TARGET = RelayFormat.OPENAI_RESPONSES 

45 

46 

47def _parse_arguments(arguments: str) -> dict[str, Any] | str: 

48 """Parse a wire arguments string into dict form when possible. 

49 

50 Args: 

51 arguments: Raw JSON argument string from the wild. 

52 

53 Returns: 

54 The parsed dict, or the original string when it is empty or not 

55 valid JSON. 

56 """ 

57 if not isinstance(arguments, str) or not arguments.strip(): 

58 return arguments 

59 try: 

60 value = loads_str(arguments) 

61 except (ValueError, TypeError): 

62 return arguments 

63 if isinstance(value, dict): 

64 return value 

65 return arguments 

66 

67 

68def _arguments_to_wire(arguments: Any) -> str: 

69 """Serialize canonical arguments into a JSON string. 

70 

71 Args: 

72 arguments: Canonical arguments (dict, string, or anything else). 

73 

74 Returns: 

75 A JSON string for the wire, or an empty string when unsupported. 

76 """ 

77 if isinstance(arguments, dict): 

78 return dumps_str(arguments) 

79 if isinstance(arguments, str): 

80 return arguments 

81 return "" 

82 

83 

84class OpenAIResponsesMapper: 

85 """Bidirectional OpenAI Responses converter. 

86 

87 Attributes: 

88 format: The wire format this mapper handles. 

89 """ 

90 

91 format = _TARGET 

92 

93 def request_to_ir( 

94 self, payload: Any, *, context: ConversionContext 

95 ) -> Result[RelayRequest, RelayError]: 

96 """Convert a ``ResponsesRequest`` into canonical ``RelayRequest``. 

97 

98 Args: 

99 payload: A wire request DTO. 

100 context: Per-conversion context with loss sink. 

101 

102 Returns: 

103 Ok(request) on success, Err(relay_error) on malformed payload. 

104 """ 

105 if not isinstance(payload, ResponsesRequest): 

106 return Err( 

107 unsupported_format( 

108 f"expected ResponsesRequest, got {type(payload).__name__}" 

109 ) 

110 ) 

111 system_parts: list[str] = [] 

112 if payload.instructions: 

113 system_parts.append(payload.instructions) 

114 messages: list[ChatMessage] = [] 

115 pending_tools: list[ToolCall] = [] 

116 pending_ids: list[str | None] = [] 

117 web_search_calls: list[dict[str, Any]] = [] 

118 

119 def flush_tools() -> None: 

120 """Emit accumulated function calls as one assistant turn.""" 

121 if not pending_tools: 

122 return 

123 metadata: dict[str, Any] = {} 

124 if any(pending_ids): 

125 metadata["function_call_item_ids"] = list(pending_ids) 

126 messages.append( 

127 ChatMessage( 

128 role="assistant", 

129 content="", 

130 tool_calls=list(pending_tools), 

131 metadata=metadata or None, 

132 ) 

133 ) 

134 pending_tools.clear() 

135 pending_ids.clear() 

136 

137 if isinstance(payload.input, str): 

138 messages.append(ChatMessage(role="user", content=payload.input)) 

139 else: 

140 for index, wire_item in enumerate(payload.input): 

141 item_type = wire_item.type 

142 if item_type == "message": 

143 flush_tools() 

144 if wire_item.role == "system": 

145 system_parts.append(self._message_text_to_ir(wire_item)) 

146 if index > 0: 

147 record_loss( 

148 context, 

149 field="system_message", 

150 target=_TARGET, 

151 reason="system_message_reordered", 

152 ) 

153 continue 

154 messages.append(self._message_from_item(wire_item, context)) 

155 elif item_type == "function_call": 

156 pending_tools.append(self._tool_from_item(wire_item)) 

157 pending_ids.append(wire_item.id or wire_item.call_id) 

158 elif item_type == "function_call_output": 

159 flush_tools() 

160 messages.append(self._tool_result_from_item(wire_item)) 

161 elif item_type == "reasoning": 

162 flush_tools() 

163 messages.append(self._reasoning_from_item(wire_item)) 

164 elif item_type == "web_search_call": 

165 web_search_calls.append(wire_item.to_dict()) 

166 record_loss( 

167 context, 

168 field=f"input[{index}]", 

169 target=_TARGET, 

170 reason="unsupported_item_preserved", 

171 severity="info", 

172 ) 

173 else: 

174 record_loss( 

175 context, 

176 field=f"input[{index}]", 

177 target=_TARGET, 

178 reason="unknown_item_dropped", 

179 ) 

180 flush_tools() 

181 metadata: dict[str, Any] = {} 

182 if payload.include is not None: 

183 metadata["include"] = list(payload.include) 

184 if payload.max_output_tokens is not None: 

185 metadata["max_tokens_kind"] = "max_completion_tokens" 

186 if web_search_calls: 

187 metadata["input_web_search_calls"] = web_search_calls 

188 reasoning = payload.reasoning 

189 if reasoning is not None: 

190 metadata["reasoning"] = reasoning 

191 if payload.text is not None: 

192 metadata["text"] = payload.text 

193 if payload.service_tier is not None: 

194 metadata["service_tier"] = payload.service_tier 

195 thinking: ThinkingConfig | None = None 

196 if isinstance(reasoning, dict): 

197 thinking = ThinkingConfig(effort=reasoning.get("effort")) 

198 return Ok( 

199 RelayRequest( 

200 model=context.normalize_model(payload.model), 

201 messages=messages, 

202 system="\n".join(system_parts) if system_parts else None, 

203 tools=self._tools_to_ir(payload.tools, context), 

204 temperature=payload.temperature, 

205 max_tokens=payload.max_output_tokens, 

206 stream=payload.stream, 

207 include_usage=bool(payload.include and "usage" in payload.include), 

208 parallel_tool_calls=payload.parallel_tool_calls, 

209 thinking=thinking, 

210 response_format=self._text_to_response_format(payload.text), 

211 metadata=metadata, 

212 passthrough=dict(payload.passthrough), 

213 ) 

214 ) 

215 

216 def ir_to_request( 

217 self, request: RelayRequest, *, context: ConversionContext 

218 ) -> Result[Any, RelayError]: 

219 """Convert canonical ``RelayRequest`` into a ``ResponsesRequest``. 

220 

221 Args: 

222 request: Canonical request IR. 

223 context: Per-conversion context with loss sink. 

224 

225 Returns: 

226 Ok(request) on success, Err(relay_error) on failure. 

227 """ 

228 try: 

229 items: list[ResponsesItem] = [] 

230 instructions = request.system 

231 system_parts = [request.system] if request.system else [] 

232 for message in request.messages: 

233 if message.role == "system": 

234 system_parts.append(self._system_text(message)) 

235 record_loss( 

236 context, 

237 field="system_message", 

238 target=_TARGET, 

239 reason="system_message_reordered", 

240 ) 

241 continue 

242 if message.role == "tool": 

243 items.append(self._tool_result_to_item(message)) 

244 continue 

245 if message.tool_calls: 

246 items.extend(self._tool_calls_to_items(message, context)) 

247 continue 

248 items.append(self._message_to_item(message, context)) 

249 items.extend(self._web_search_items(request)) 

250 if system_parts: 

251 instructions = "\n".join(system_parts) 

252 handled_metadata = { 

253 "include", 

254 "reasoning", 

255 "text", 

256 "service_tier", 

257 "input_web_search_calls", 

258 "generation_config", 

259 "safety_settings", 

260 "tool_config", 

261 } 

262 return Ok( 

263 ResponsesRequest( 

264 model=context.resolve_model(request.model), 

265 input=items, 

266 instructions=instructions, 

267 tools=( 

268 [self._tool_from_ir(tool) for tool in request.tools] 

269 if request.tools 

270 else None 

271 ), 

272 temperature=request.temperature, 

273 max_output_tokens=request.max_tokens, 

274 stream=request.stream, 

275 include=self._include_from_ir(request), 

276 parallel_tool_calls=request.parallel_tool_calls, 

277 reasoning=self._reasoning_from_ir(request, context), 

278 text=self._text_from_ir(request), 

279 service_tier=request.metadata.get("service_tier"), 

280 tool_choice=request.tool_choice, 

281 passthrough={ 

282 **request.passthrough, 

283 **{ 

284 key: value 

285 for key, value in request.metadata.items() 

286 if key not in handled_metadata 

287 }, 

288 }, 

289 ) 

290 ) 

291 except (RelayError, ValueError, TypeError, KeyError) as exc: 

292 return Err(translate(exc, detail="ir_to_request")) 

293 

294 def response_to_ir( 

295 self, payload: Any, *, context: ConversionContext 

296 ) -> Result[RelayResponse, RelayError]: 

297 """Convert a ``ResponsesResponse`` into canonical ``RelayResponse``. 

298 

299 Args: 

300 payload: A wire response DTO. 

301 context: Per-conversion context with loss sink. 

302 

303 Returns: 

304 Ok(response) on success, Err(relay_error) on malformed payload. 

305 """ 

306 if not isinstance(payload, ResponsesResponse): 

307 return Err( 

308 unsupported_format( 

309 f"expected ResponsesResponse, got {type(payload).__name__}" 

310 ) 

311 ) 

312 try: 

313 passthrough = dict(payload.passthrough) 

314 if payload.error is not None: 

315 passthrough["error"] = payload.error 

316 if payload.object != "response": 

317 passthrough["object"] = payload.object 

318 content_parts: list[str] = [] 

319 tool_calls: list[ToolCall] = [] 

320 tool_results: list[ChatMessage] = [] 

321 reasoning_text: list[str] = [] 

322 web_search_calls: list[dict[str, Any]] = [] 

323 for index, output_item in enumerate(payload.output): 

324 item_type = output_item.type 

325 if item_type == "message": 

326 for part in output_item.content or []: 

327 if not isinstance(part, dict): 

328 content_parts.append(str(part)) 

329 continue 

330 part_type = part.get("type") 

331 if part_type == "output_text": 

332 content_parts.append(str(part.get("text", ""))) 

333 else: 

334 record_loss( 

335 context, 

336 field=part_type or "part", 

337 target=_TARGET, 

338 reason="unknown_part_type", 

339 ) 

340 elif item_type == "reasoning": 

341 reasoning_text.extend(self._summary_texts(output_item.summary)) 

342 elif item_type == "function_call": 

343 tool_calls.append( 

344 ToolCall( 

345 id=output_item.call_id or output_item.id or "", 

346 type="function", 

347 function=FunctionCall( 

348 name=output_item.name or "", 

349 arguments=_parse_arguments(output_item.arguments or ""), 

350 ), 

351 ) 

352 ) 

353 elif item_type == "function_call_output": 

354 tool_results.append( 

355 ChatMessage( 

356 role="tool", 

357 content=output_item.output or "", 

358 tool_call_id=output_item.call_id, 

359 ) 

360 ) 

361 elif item_type == "web_search_call": 

362 web_search_calls.append(output_item.to_dict()) 

363 record_loss( 

364 context, 

365 field=f"output[{index}]", 

366 target=_TARGET, 

367 reason="unsupported_item_preserved", 

368 severity="info", 

369 ) 

370 else: 

371 record_loss( 

372 context, 

373 field=f"output[{index}]", 

374 target=_TARGET, 

375 reason="unknown_item_dropped", 

376 ) 

377 if web_search_calls: 

378 passthrough["web_search_calls"] = web_search_calls 

379 thinking: ThinkingResult | None = None 

380 if reasoning_text: 

381 tokens: int | None = None 

382 if payload.usage is not None: 

383 details = payload.usage.output_tokens_details 

384 if isinstance(details, dict) and isinstance( 

385 details.get("reasoning_tokens"), int 

386 ): 

387 tokens = details["reasoning_tokens"] 

388 thinking = ThinkingResult( 

389 content="".join(reasoning_text), tokens=tokens 

390 ) 

391 return Ok( 

392 RelayResponse( 

393 model=payload.model, 

394 id=payload.id, 

395 created=payload.created_at, 

396 content="".join(content_parts), 

397 thinking=thinking, 

398 tool_calls=tool_calls, 

399 tool_results=tool_results, 

400 finish_reason=self._finish_from_status( 

401 payload.status, 

402 payload.incomplete_details, 

403 bool(tool_calls), 

404 ), 

405 status=payload.status, 

406 incomplete_details=( 

407 payload.incomplete_details.to_dict() 

408 if payload.incomplete_details is not None 

409 else None 

410 ), 

411 usage=self._usage_from_wire(payload.usage), 

412 passthrough=passthrough, 

413 ) 

414 ) 

415 except (RelayError, ValueError, TypeError, KeyError) as exc: 

416 return Err(translate(exc, detail="response_to_ir")) 

417 

418 def ir_to_response( 

419 self, response: RelayResponse, *, context: ConversionContext 

420 ) -> Result[Any, RelayError]: 

421 """Convert canonical ``RelayResponse`` into a ``ResponsesResponse``. 

422 

423 Args: 

424 response: Canonical response IR. 

425 context: Per-conversion context with loss sink. 

426 

427 Returns: 

428 Ok(response) on success, Err(relay_error) on failure. 

429 """ 

430 try: 

431 passthrough = dict(response.passthrough) 

432 error = passthrough.pop("error", None) 

433 object_type = passthrough.pop("object", "response") 

434 status, incomplete = self._status_from_finish(response) 

435 response_id = response.id or f"chatcmpl-{new_uuid()}" 

436 item_status = "incomplete" if status == "incomplete" else "completed" 

437 items: list[ResponsesItem] = [] 

438 content_parts: list[dict[str, Any]] = [] 

439 if response.content: 

440 content_parts.append( 

441 { 

442 "type": "output_text", 

443 "text": response.content, 

444 "annotations": [], 

445 } 

446 ) 

447 if content_parts: 

448 items.append( 

449 ResponsesItem( 

450 type="message", 

451 role="assistant", 

452 id=f"{response_id}_msg_0", 

453 status=item_status, 

454 content=content_parts, 

455 quality="", 

456 size="", 

457 ) 

458 ) 

459 if response.thinking is not None and response.thinking.content: 

460 items.append( 

461 ResponsesItem( 

462 type="reasoning", 

463 id=f"{response_id}_reasoning_0", 

464 status=item_status, 

465 role="", 

466 content=[ 

467 { 

468 "type": "summary_text", 

469 "text": response.thinking.content, 

470 "annotations": None, 

471 } 

472 ], 

473 quality="", 

474 size="", 

475 ) 

476 ) 

477 for tool in response.tool_calls: 

478 call_id = tool.id or f"call_{new_uuid()}" 

479 items.append( 

480 ResponsesItem( 

481 type="function_call", 

482 id=call_id, 

483 status=item_status, 

484 role="", 

485 content=None, 

486 quality="", 

487 size="", 

488 call_id=call_id, 

489 name=tool.function.name if tool.function else "", 

490 arguments=_arguments_to_wire( 

491 tool.function.arguments if tool.function else {} 

492 ), 

493 ) 

494 ) 

495 for index, result in enumerate(response.tool_results): 

496 items.append( 

497 ResponsesItem( 

498 type="function_call_output", 

499 id=f"fcoc_{index}", 

500 call_id=result.tool_call_id, 

501 output=self._result_output(result), 

502 ) 

503 ) 

504 return Ok( 

505 ResponsesResponse( 

506 id=response_id, 

507 model=context.resolve_model(response.model), 

508 output=items, 

509 object=object_type, 

510 created_at=response.created or 0, 

511 status=status, 

512 incomplete_details=incomplete, 

513 error=error if isinstance(error, dict) else None, 

514 usage=self._usage_to_wire(response.usage), 

515 passthrough=passthrough, 

516 ) 

517 ) 

518 except (RelayError, ValueError, TypeError, KeyError) as exc: 

519 return Err(translate(exc, detail="ir_to_response")) 

520 

521 def stream_to_delta( 

522 self, event: Any, *, state: StreamState 

523 ) -> Result[tuple[StreamDelta, ...], RelayError]: 

524 """Stream conversion is deferred to the shared stream lifecycle task.""" 

525 return Err( 

526 unsupported_feature( 

527 "openai_responses stream conversion is not implemented yet" 

528 ) 

529 ) 

530 

531 def delta_to_stream( 

532 self, delta: StreamDelta, *, state: StreamState 

533 ) -> Result[tuple[Any, ...], RelayError]: 

534 """Stream emission is deferred to the shared stream lifecycle task.""" 

535 return Err( 

536 unsupported_feature( 

537 "openai_responses stream conversion is not implemented yet" 

538 ) 

539 ) 

540 

541 # -- helpers ------------------------------------------------------------- 

542 

543 @staticmethod 

544 def _message_text_to_ir(wire_item: ResponsesItem) -> str: 

545 """Extract text from a wire message item.""" 

546 return "".join( 

547 str(part.get("text", "")) 

548 for part in wire_item.content or [] 

549 if isinstance(part, dict) and part.get("type") == "input_text" 

550 ) 

551 

552 @staticmethod 

553 def _input_parts_to_ir( 

554 content: list[dict[str, Any]] | None, context: ConversionContext 

555 ) -> tuple[list[Any], list[dict[str, Any]]]: 

556 """Convert wire content parts into canonical parts and files.""" 

557 converted: list[Any] = [] 

558 files: list[dict[str, Any]] = [] 

559 for part in content or []: 

560 if not isinstance(part, dict): 

561 converted.append(TextPart(text=str(part))) 

562 continue 

563 part_type = part.get("type") 

564 if part_type == "input_text": 

565 converted.append(TextPart(text=str(part.get("text", "")))) 

566 elif part_type == "input_image": 

567 image = part.get("image_url") 

568 if isinstance(image, dict): 

569 converted.append( 

570 ImageUrlPart( 

571 url=str(image.get("url", "")), 

572 detail=cast("Any", image.get("detail", "auto") or "auto"), 

573 ) 

574 ) 

575 else: 

576 converted.append( 

577 ImageUrlPart( 

578 url=str(image or ""), 

579 detail=cast("Any", part.get("detail", "auto") or "auto"), 

580 ) 

581 ) 

582 elif part_type == "input_file": 

583 files.append(part) 

584 record_loss( 

585 context, 

586 field="content", 

587 target=_TARGET, 

588 reason="unrepresentable_part_preserved", 

589 severity="info", 

590 ) 

591 else: 

592 record_loss( 

593 context, 

594 field=part_type or "part", 

595 target=_TARGET, 

596 reason="unknown_part_type", 

597 ) 

598 return converted, files 

599 

600 def _message_from_item( 

601 self, wire_item: ResponsesItem, context: ConversionContext 

602 ) -> ChatMessage: 

603 """Convert a wire message item into a canonical message.""" 

604 wire_content = wire_item.content 

605 if isinstance(wire_content, str): 

606 wire_content = [{"type": "input_text", "text": wire_content}] 

607 parts, files = self._input_parts_to_ir(wire_content, context) 

608 metadata: dict[str, Any] = {} 

609 if wire_item.id: 

610 metadata["item_id"] = wire_item.id 

611 if files: 

612 metadata["input_files"] = files 

613 content: str | list[Any] 

614 if len(parts) == 1 and isinstance(parts[0], TextPart): 

615 content = parts[0].text 

616 elif parts: 

617 content = parts 

618 else: 

619 content = "" 

620 return ChatMessage( 

621 role=wire_item.role or "user", 

622 content=content, 

623 metadata=metadata or None, 

624 ) 

625 

626 @staticmethod 

627 def _tool_from_item(wire_item: ResponsesItem) -> ToolCall: 

628 """Convert a wire function_call item into a canonical tool call.""" 

629 return ToolCall( 

630 id=wire_item.call_id or wire_item.id or "", 

631 type="function", 

632 function=FunctionCall( 

633 name=wire_item.name or "", 

634 arguments=_parse_arguments(wire_item.arguments or ""), 

635 ), 

636 ) 

637 

638 @staticmethod 

639 def _tool_result_from_item(wire_item: ResponsesItem) -> ChatMessage: 

640 """Convert a wire function_call_output item into a tool message.""" 

641 metadata: dict[str, Any] = {} 

642 if wire_item.id: 

643 metadata["item_id"] = wire_item.id 

644 return ChatMessage( 

645 role="tool", 

646 content=wire_item.output or "", 

647 tool_call_id=wire_item.call_id, 

648 metadata=metadata or None, 

649 ) 

650 

651 @staticmethod 

652 def _reasoning_from_item(wire_item: ResponsesItem) -> ChatMessage: 

653 """Convert a wire reasoning item into an assistant message.""" 

654 metadata: dict[str, Any] = {} 

655 if wire_item.id: 

656 metadata["item_id"] = wire_item.id 

657 return ChatMessage( 

658 role="assistant", 

659 content="", 

660 thinking_blocks=list(wire_item.summary or []), 

661 metadata=metadata or None, 

662 ) 

663 

664 def _message_to_item( 

665 self, message: ChatMessage, context: ConversionContext 

666 ) -> ResponsesItem: 

667 """Convert a canonical message into a wire message item.""" 

668 data: dict[str, Any] = {"role": message.role} 

669 if message.metadata and message.metadata.get("item_id"): 

670 data["id"] = message.metadata["item_id"] 

671 files = (message.metadata or {}).get("input_files") 

672 has_files = isinstance(files, list) and any( 

673 isinstance(item, dict) for item in files 

674 ) 

675 if isinstance(message.content, str): 

676 if message.content and not has_files: 

677 data["content"] = message.content 

678 elif message.content or has_files: 

679 parts = self._message_content_parts(message, context) 

680 if parts: 

681 data["content"] = parts 

682 else: 

683 parts = self._message_content_parts(message, context) 

684 if parts: 

685 data["content"] = parts 

686 return ResponsesItem(**data) 

687 

688 @staticmethod 

689 def _message_content_parts( 

690 message: ChatMessage, context: ConversionContext 

691 ) -> list[dict[str, Any]]: 

692 """Serialize canonical content into wire message parts.""" 

693 parts: list[dict[str, Any]] = [] 

694 content = message.content 

695 if isinstance(content, str): 

696 if content: 

697 parts.append({"type": "input_text", "text": content}) 

698 elif isinstance(content, list): 

699 for part in content: 

700 if isinstance(part, TextPart): 

701 parts.append({"type": "input_text", "text": part.text}) 

702 elif isinstance(part, ImageUrlPart): 

703 parts.append( 

704 { 

705 "type": "input_image", 

706 "image_url": part.url, 

707 } 

708 ) 

709 elif isinstance(part, ImageBase64Part): 

710 parts.append( 

711 { 

712 "type": "input_image", 

713 "image_url": f"data:{part.media_type};base64,{part.data}", 

714 } 

715 ) 

716 else: 

717 record_loss( 

718 context, 

719 field="message.content", 

720 target=_TARGET, 

721 reason="unknown_content_part", 

722 ) 

723 files = (message.metadata or {}).get("input_files") 

724 if isinstance(files, list): 

725 for file_part in files: 

726 if isinstance(file_part, dict): 

727 parts.append(file_part) 

728 return parts 

729 

730 def _tool_calls_to_items( 

731 self, message: ChatMessage, context: ConversionContext 

732 ) -> list[ResponsesItem]: 

733 """Convert a tool-calling assistant turn into wire items.""" 

734 items: list[ResponsesItem] = [] 

735 if self._message_content_parts(message, context): 

736 items.append(self._message_to_item(message, context)) 

737 else: 

738 items.append(ResponsesItem(role="assistant", content="")) 

739 item_ids = (message.metadata or {}).get("function_call_item_ids") 

740 for index, tool in enumerate(message.tool_calls or []): 

741 data: dict[str, Any] = { 

742 "type": "function_call", 

743 "call_id": tool.id or f"call_{index + 1}", 

744 "name": tool.function.name if tool.function else "", 

745 "arguments": _arguments_to_wire( 

746 tool.function.arguments if tool.function else {} 

747 ), 

748 } 

749 if isinstance(item_ids, list) and index < len(item_ids) and item_ids[index]: 

750 data["id"] = item_ids[index] 

751 items.append(ResponsesItem(**data)) 

752 return items 

753 

754 def _thinking_to_items(self, message: ChatMessage) -> list[ResponsesItem]: 

755 """Convert canonical thinking blocks into a reasoning item.""" 

756 summary = list(message.thinking_blocks or []) 

757 if not summary: 

758 return [] 

759 data: dict[str, Any] = {"type": "reasoning", "summary": summary} 

760 if message.metadata and message.metadata.get("item_id"): 

761 data["id"] = message.metadata["item_id"] 

762 return [ResponsesItem(**data)] 

763 

764 @staticmethod 

765 def _tool_result_to_item(message: ChatMessage) -> ResponsesItem: 

766 """Convert a canonical tool message into a wire output item.""" 

767 content = message.content 

768 if isinstance(content, list): 

769 output = "".join( 

770 part.text for part in content if isinstance(part, TextPart) 

771 ) 

772 else: 

773 output = content or "" 

774 data: dict[str, Any] = { 

775 "type": "function_call_output", 

776 "output": str(output), 

777 "call_id": message.tool_call_id or "call_0", 

778 } 

779 if message.metadata and message.metadata.get("item_id"): 

780 data["id"] = message.metadata["item_id"] 

781 return ResponsesItem(**data) 

782 

783 @staticmethod 

784 def _system_text(message: ChatMessage) -> str: 

785 """Extract text from a system-role canonical message.""" 

786 content = message.content 

787 if isinstance(content, str): 

788 return content 

789 return "".join(part.text for part in content if isinstance(part, TextPart)) 

790 

791 @staticmethod 

792 def _web_search_items(request: RelayRequest) -> list[ResponsesItem]: 

793 """Restore preserved web_search_call input items.""" 

794 raw = request.metadata.get("input_web_search_calls") 

795 items: list[ResponsesItem] = [] 

796 if isinstance(raw, list): 

797 for entry in raw: 

798 if not isinstance(entry, dict): 

799 continue 

800 data = dict(entry) 

801 items.append( 

802 ResponsesItem( 

803 type=str(data.pop("type", "web_search_call")), 

804 id=data.pop("id", None), 

805 passthrough=data, 

806 ) 

807 ) 

808 return items 

809 

810 @staticmethod 

811 def _tools_to_ir( 

812 tools: list[dict[str, Any]] | None, context: ConversionContext 

813 ) -> list[ToolDefinition]: 

814 """Convert wire tool dicts into canonical tool definitions.""" 

815 definitions: list[ToolDefinition] = [] 

816 if not tools: 

817 return definitions 

818 for index, tool in enumerate(tools): 

819 if not isinstance(tool, dict): 

820 record_loss( 

821 context, 

822 field=f"tools[{index}]", 

823 target=_TARGET, 

824 reason="non_dict_tool_dropped", 

825 ) 

826 continue 

827 if tool.get("type", "function") != "function": 

828 record_loss( 

829 context, 

830 field=f"tools[{index}]", 

831 target=_TARGET, 

832 reason="non_function_tool_dropped", 

833 ) 

834 continue 

835 if isinstance(tool.get("function"), dict): 

836 function = tool["function"] 

837 else: 

838 function = tool 

839 parameters = function.get("parameters", {}) 

840 definitions.append( 

841 ToolDefinition( 

842 name=str(function.get("name", "")), 

843 description=str(function.get("description", "")), 

844 parameters=parameters if isinstance(parameters, dict) else {}, 

845 ) 

846 ) 

847 return definitions 

848 

849 @staticmethod 

850 def _tool_from_ir(tool: ToolDefinition) -> dict[str, Any]: 

851 """Serialize a canonical tool definition as a wire tool dict.""" 

852 return { 

853 "type": "function", 

854 "name": tool.name, 

855 "description": tool.description, 

856 "parameters": tool.parameters, 

857 } 

858 

859 @staticmethod 

860 def _text_to_response_format( 

861 text: dict[str, Any] | None, 

862 ) -> dict[str, Any] | None: 

863 """Derive a canonical response format from the wire text config.""" 

864 if not isinstance(text, dict): 

865 return None 

866 fmt = text.get("format") 

867 if not isinstance(fmt, dict): 

868 return None 

869 fmt_type = fmt.get("type") 

870 if fmt_type == "json_object": 

871 return {"type": "json_object"} 

872 if fmt_type == "json_schema": 

873 result: dict[str, Any] = {"type": "json_schema"} 

874 for key in ("schema", "name", "strict"): 

875 if key in fmt: 

876 result[key] = fmt[key] 

877 return result 

878 return None 

879 

880 @classmethod 

881 def _text_from_ir(cls, request: RelayRequest) -> dict[str, Any] | None: 

882 """Rebuild the wire text config from canonical response format.""" 

883 raw = request.metadata.get("text") 

884 text: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {} 

885 response_format = request.response_format 

886 if response_format is None: 

887 return text or None 

888 fmt_type = response_format.get("type") 

889 if fmt_type == "json_object": 

890 text["format"] = {"type": "json_object"} 

891 elif fmt_type == "json_schema": 

892 fmt: dict[str, Any] = {"type": "json_schema"} 

893 for key in ("schema", "name", "strict"): 

894 if key in response_format: 

895 fmt[key] = response_format[key] 

896 text["format"] = fmt 

897 else: 

898 text["format"] = {"type": "text"} 

899 return text or None 

900 

901 @staticmethod 

902 def _include_from_ir(request: RelayRequest) -> list[str] | None: 

903 """Rebuild the wire include list from canonical stream settings.""" 

904 raw = request.metadata.get("include") 

905 include = list(raw) if isinstance(raw, list) else [] 

906 if request.include_usage and "usage" not in include: 

907 include.append("usage") 

908 return include or None 

909 

910 def _reasoning_from_ir( 

911 self, request: RelayRequest, context: ConversionContext 

912 ) -> dict[str, Any] | None: 

913 """Rebuild the wire reasoning config from canonical thinking.""" 

914 thinking = request.thinking 

915 if thinking is not None: 

916 if thinking.effort is not None: 

917 return {"effort": thinking.effort} 

918 record_loss( 

919 context, 

920 field="thinking", 

921 target=_TARGET, 

922 reason="effort_only_supported", 

923 ) 

924 raw = request.metadata.get("reasoning") 

925 if isinstance(raw, dict): 

926 return dict(raw) 

927 return None 

928 

929 @staticmethod 

930 def _summary_texts( 

931 summary: list[dict[str, Any]] | None, 

932 ) -> list[str]: 

933 """Extract text from reasoning summary blocks.""" 

934 return [ 

935 str(item.get("text", "")) 

936 for item in summary or [] 

937 if isinstance(item, dict) and item.get("type") == "summary_text" 

938 ] 

939 

940 @staticmethod 

941 def _finish_from_status( 

942 status: str | None, 

943 incomplete_details: ResponsesIncompleteDetails | None, 

944 has_tool_calls: bool, 

945 ) -> str | None: 

946 """Derive a canonical finish reason from a wire status.""" 

947 if status == "completed": 

948 return "tool_calls" if has_tool_calls else "stop" 

949 if status == "incomplete": 

950 reason = ( 

951 incomplete_details.reason if incomplete_details is not None else None 

952 ) 

953 if reason == "max_output_tokens": 

954 return "length" 

955 if reason == "content_filter": 

956 return "content_filter" 

957 return "other" 

958 if status == "failed": 

959 return "other" 

960 return None 

961 

962 @staticmethod 

963 def _status_from_finish( 

964 response: RelayResponse, 

965 ) -> tuple[str | None, ResponsesIncompleteDetails | None]: 

966 """Derive a wire status from canonical finish behavior.""" 

967 status = response.status 

968 incomplete: ResponsesIncompleteDetails | None = None 

969 if response.incomplete_details is not None: 

970 raw = dict(response.incomplete_details) 

971 reason = raw.pop("reason", None) 

972 incomplete = ResponsesIncompleteDetails(reason=reason, passthrough=raw) 

973 if status is not None: 

974 if status == "incomplete" and incomplete is None: 

975 derived = _incomplete_for_finish(response.finish_reason) 

976 if derived is not None: 

977 incomplete = derived 

978 return status, incomplete 

979 finish = response.finish_reason 

980 wire_status, detail = responses_status_from_finish(finish) 

981 if detail is None: 

982 return wire_status, None 

983 return wire_status, ResponsesIncompleteDetails(reason=detail) 

984 

985 @staticmethod 

986 def _result_output(message: ChatMessage) -> str: 

987 """Extract a tool result string from a canonical tool message.""" 

988 content = message.content 

989 if isinstance(content, list): 

990 return "".join(part.text for part in content if isinstance(part, TextPart)) 

991 return str(content or "") 

992 

993 @staticmethod 

994 def _usage_from_wire(usage: ResponsesUsage | None) -> RelayUsage | None: 

995 """Map wire usage into canonical ``RelayUsage``.""" 

996 if usage is None: 

997 return None 

998 input_details = usage.input_tokens_details 

999 completion_details = usage.completion_tokens_details 

1000 if not isinstance(completion_details, dict): 

1001 completion_details = usage.output_tokens_details 

1002 return RelayUsage( 

1003 prompt_tokens=usage.prompt_tokens or usage.input_tokens, 

1004 completion_tokens=usage.completion_tokens or usage.output_tokens, 

1005 total_tokens_override=usage.total_tokens or None, 

1006 cache_read_tokens=( 

1007 int(input_details.get("cached_tokens", 0) or 0) 

1008 if isinstance(input_details, dict) 

1009 else 0 

1010 ), 

1011 reasoning_tokens=( 

1012 int(completion_details.get("reasoning_tokens", 0) or 0) 

1013 if isinstance(completion_details, dict) 

1014 else 0 

1015 ), 

1016 input_tokens=usage.input_tokens, 

1017 output_tokens=usage.output_tokens, 

1018 ) 

1019 

1020 @staticmethod 

1021 def _usage_to_wire(usage: RelayUsage | None) -> ResponsesUsage | None: 

1022 """Serialize canonical ``RelayUsage`` into wire usage.""" 

1023 if usage is None: 

1024 return None 

1025 input_details = ( 

1026 {"cached_tokens": usage.cache_read_tokens} 

1027 if usage.cache_read_tokens 

1028 else None 

1029 ) 

1030 return ResponsesUsage( 

1031 prompt_tokens=usage.prompt_tokens, 

1032 completion_tokens=usage.completion_tokens, 

1033 total_tokens=usage.total_tokens, 

1034 prompt_tokens_details={"cached_tokens": 0}, 

1035 completion_tokens_details={"reasoning_tokens": usage.reasoning_tokens}, 

1036 input_tokens=usage.prompt_tokens, 

1037 input_tokens_details=input_details, 

1038 output_tokens=usage.completion_tokens, 

1039 ) 

1040 

1041 

1042def _incomplete_for_finish( 

1043 finish_reason: str | None, 

1044) -> ResponsesIncompleteDetails | None: 

1045 """Map a canonical finish reason to an incomplete-details payload.""" 

1046 detail = responses_incomplete_from_finish(finish_reason) 

1047 if detail is None: 

1048 return None 

1049 return ResponsesIncompleteDetails(reason=detail)