Coverage for src / lexigram / ai / relay / mappers / claude.py: 87%
343 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-08 23:08 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-08 23:08 +0800
1"""Anthropic Claude Messages request and response mapper.
3Converts the Claude Messages wire DTOs
4(:class:`ClaudeRequest` / :class:`ClaudeResponse`) into the canonical
5relay IR and back. Stream conversion is handled by the shared stream
6lifecycle task and reports ``unsupported_feature`` until then.
7"""
9from __future__ import annotations
11from dataclasses import replace
12from typing import Any
14from lexigram.ai.relay.context import ConversionContext
15from lexigram.ai.relay.errors import (
16 missing_required_option,
17 translate,
18 unsupported_feature,
19 unsupported_format,
20)
21from lexigram.ai.relay.finish_reasons import (
22 FINISH_REASON_TO_WIRE,
23 finish_reason_to_wire,
24)
25from lexigram.ai.relay.mappers.base import new_uuid, record_loss
26from lexigram.ai.relay.media import resolve_media
27from lexigram.contracts.ai.agents import ToolDefinition
28from lexigram.contracts.ai.exceptions import RelayError
29from lexigram.contracts.ai.llm import ChatMessage, FunctionCall, ToolCall
30from lexigram.contracts.ai.multimodal import (
31 ContentPart,
32 ImageBase64Part,
33 ImageUrlPart,
34 TextPart,
35)
36from lexigram.contracts.ai.relay.dto import (
37 ClaudeContent,
38 ClaudeMessage,
39 ClaudeRequest,
40 ClaudeResponse,
41 ClaudeUsage,
42)
43from lexigram.contracts.ai.relay.ir import (
44 RelayRequest,
45 RelayResponse,
46 StreamDelta,
47 StreamState,
48 normalize_finish_reason,
49)
50from lexigram.contracts.ai.relay.types import RelayFormat, RelayUsage
51from lexigram.contracts.ai.thinking import ThinkingConfig, ThinkingResult
52from lexigram.contracts.core.result import Err, Ok, Result
53from lexigram.serialization import loads_str
55__all__ = ["ClaudeMapper"]
57_TARGET = RelayFormat.CLAUDE
60def _tool_call_from_block(block: ClaudeContent) -> ToolCall:
61 """Convert a Claude ``tool_use`` block into a canonical ``ToolCall``."""
62 return ToolCall(
63 id=block.tool_use_id or "",
64 type="custom",
65 function=FunctionCall(name=block.name or "", arguments=block.input or {}),
66 )
69def _tool_call_to_block(tool_call: ToolCall) -> ClaudeContent:
70 """Serialize a canonical ``ToolCall`` as a Claude ``tool_use`` block."""
71 arguments: Any = tool_call.function.arguments if tool_call.function else {}
72 if isinstance(arguments, str):
73 try:
74 arguments = loads_str(arguments)
75 except ValueError:
76 arguments = {}
77 elif not isinstance(arguments, dict):
78 arguments = {}
79 return ClaudeContent(
80 type="tool_use",
81 tool_use_id=tool_call.id or f"call_{new_uuid()}",
82 name=tool_call.function.name if tool_call.function else "",
83 input=arguments,
84 )
87class ClaudeMapper:
88 """Bidirectional Anthropic Claude Messages converter.
90 Attributes:
91 format: The wire format this mapper handles.
92 """
94 format = _TARGET
96 def request_to_ir(
97 self, payload: Any, *, context: ConversionContext
98 ) -> Result[RelayRequest, RelayError]:
99 """Convert a ``ClaudeRequest`` into canonical ``RelayRequest``.
101 Args:
102 payload: A wire request DTO.
103 context: Per-conversion context with loss sink.
105 Returns:
106 Ok(request) on success, Err(relay_error) on malformed payload.
107 """
108 if not isinstance(payload, ClaudeRequest):
109 return Err(
110 unsupported_format(
111 f"expected ClaudeRequest, got {type(payload).__name__}"
112 )
113 )
114 try:
115 tool_names: dict[str, str] = {}
116 messages: list[ChatMessage] = []
117 for index, message in enumerate(payload.messages):
118 messages.extend(
119 self._message_to_ir(message, context, index, tool_names)
120 )
121 thinking: ThinkingConfig | None = None
122 if isinstance(payload.thinking, dict):
123 if payload.thinking.get("type") == "enabled":
124 thinking = ThinkingConfig(
125 budget_tokens=int(payload.thinking.get("budget_tokens", 0) or 0)
126 )
127 metadata: dict[str, Any] = {}
128 if payload.metadata is not None:
129 metadata["metadata"] = payload.metadata
130 return Ok(
131 RelayRequest(
132 model=context.normalize_model(payload.model),
133 messages=messages,
134 system=self._system_to_ir(payload.system, context),
135 tools=self._tools_to_ir(payload.tools, context),
136 tool_choice=payload.tool_choice,
137 temperature=payload.temperature,
138 top_p=payload.top_p,
139 max_tokens=payload.max_tokens,
140 stop_sequences=list(payload.stop_sequences or []),
141 stream=payload.stream,
142 thinking=thinking,
143 metadata=metadata,
144 passthrough=dict(payload.passthrough),
145 )
146 )
147 except (RelayError, ValueError, TypeError, KeyError) as exc:
148 return Err(translate(exc, detail="request_to_ir"))
150 def ir_to_request(
151 self, request: RelayRequest, *, context: ConversionContext
152 ) -> Result[Any, RelayError]:
153 """Convert canonical ``RelayRequest`` into a ``ClaudeRequest``.
155 Args:
156 request: Canonical request IR.
157 context: Per-conversion context with loss sink.
159 Returns:
160 Ok(request) on success, Err(relay_error) on failure.
161 """
162 max_tokens = request.max_tokens
163 if max_tokens is None:
164 max_tokens = context.max_tokens_for(request.model)
165 if max_tokens is None:
166 return Err(missing_required_option("claude requires max_tokens"))
167 model = request.model
168 temperature = request.temperature
169 top_p = request.top_p
170 thinking = self._thinking_from_ir(request, context)
171 claude_options = context.options.claude
172 if claude_options.thinking_adapter_enabled and model.endswith("-thinking"):
173 if (
174 claude_options.minimum_max_tokens > 0
175 and max_tokens < claude_options.minimum_max_tokens
176 ):
177 max_tokens = claude_options.minimum_max_tokens
178 record_loss(
179 context,
180 field="max_tokens",
181 target=_TARGET,
182 reason="max_tokens_floored",
183 )
184 if thinking is None and claude_options.thinking_budget_percentage > 0:
185 thinking = {
186 "type": "enabled",
187 "budget_tokens": int(
188 max_tokens * claude_options.thinking_budget_percentage / 100
189 ),
190 }
191 temperature = 1.0
192 top_p = None
193 if (
194 not context.preserve_thinking_suffix(model)
195 and not context.options.model_suffix_preserved
196 ):
197 model = model[: -len("-thinking")]
198 try:
199 messages: list[ClaudeMessage] = []
200 system_parts: list[str] = []
201 if request.system:
202 system_parts.append(request.system)
203 for message in request.messages:
204 if message.role == "system":
205 system_parts.append(self._text_from_content(message.content))
206 continue
207 prepared = message
208 if message.role == "assistant" and message.tool_calls:
209 if any(not tool_call.id for tool_call in message.tool_calls):
210 prepared = replace(
211 message,
212 tool_calls=[
213 tool_call
214 if tool_call.id
215 else replace(tool_call, id=f"call_{index + 1}")
216 for index, tool_call in enumerate(message.tool_calls)
217 ],
218 )
219 elif message.role == "tool" and not message.tool_call_id:
220 prepared = replace(message, tool_call_id="call_0")
221 claude_message = self._message_from_ir(prepared, context)
222 if claude_message.is_err():
223 return claude_message
224 messages.append(claude_message.unwrap())
225 tool_choice = request.tool_choice
226 if isinstance(tool_choice, str):
227 tool_choice = {"type": tool_choice}
228 return Ok(
229 ClaudeRequest(
230 model=context.resolve_model(model),
231 max_tokens=max_tokens,
232 messages=messages,
233 system=(
234 [{"type": "text", "text": "\n".join(system_parts)}]
235 if system_parts
236 else None
237 ),
238 temperature=temperature,
239 top_p=top_p,
240 stream=request.stream,
241 tools=(
242 [self._tool_from_ir(tool) for tool in request.tools]
243 if request.tools
244 else None
245 ),
246 tool_choice=tool_choice,
247 stop_sequences=list(request.stop_sequences) or None,
248 thinking=thinking,
249 metadata=(
250 request.metadata.get("metadata")
251 if isinstance(request.metadata.get("metadata"), dict)
252 else None
253 ),
254 passthrough={
255 **request.passthrough,
256 **{
257 key: value
258 for key, value in request.metadata.items()
259 if key
260 not in {
261 "metadata",
262 "max_tokens_kind",
263 "generation_config",
264 "safety_settings",
265 "tool_config",
266 "reasoning",
267 "stream_options",
268 "service_tier",
269 }
270 },
271 },
272 )
273 )
274 except (RelayError, ValueError, TypeError, KeyError) as exc:
275 return Err(translate(exc, detail="ir_to_request"))
277 def response_to_ir(
278 self, payload: Any, *, context: ConversionContext
279 ) -> Result[RelayResponse, RelayError]:
280 """Convert a ``ClaudeResponse`` into canonical ``RelayResponse``.
282 Args:
283 payload: A wire response DTO.
284 context: Per-conversion context with loss sink.
286 Returns:
287 Ok(response) on success, Err(relay_error) on malformed payload.
288 """
289 if not isinstance(payload, ClaudeResponse):
290 return Err(
291 unsupported_format(
292 f"expected ClaudeResponse, got {type(payload).__name__}"
293 )
294 )
295 try:
296 text_parts: list[str] = []
297 thinking: ThinkingResult | None = None
298 tool_calls: list[ToolCall] = []
299 tool_results: list[ChatMessage] = []
300 for block in payload.content:
301 if block.type == "text":
302 if block.text is not None:
303 text_parts.append(block.text)
304 elif block.type == "thinking":
305 if thinking is None and block.thinking is not None:
306 thinking = ThinkingResult(
307 content=block.thinking, signature=block.signature
308 )
309 elif block.type == "tool_use":
310 tool_calls.append(_tool_call_from_block(block))
311 elif block.type == "tool_result":
312 result_text = "".join(
313 part.text or ""
314 for part in (block.tool_result_content or [])
315 if part.type == "text"
316 )
317 tool_results.append(
318 ChatMessage(
319 role="tool",
320 content=result_text,
321 tool_call_id=block.tool_use_id,
322 )
323 )
324 else:
325 record_loss(
326 context,
327 field=f"content.{block.type}",
328 target=_TARGET,
329 reason="unknown_block_dropped",
330 )
331 passthrough = dict(payload.passthrough)
332 if payload.stop_sequence is not None:
333 passthrough["stop_sequence"] = payload.stop_sequence
334 return Ok(
335 RelayResponse(
336 model=payload.model,
337 id=payload.id,
338 content="".join(text_parts),
339 thinking=thinking,
340 tool_calls=tool_calls,
341 tool_results=tool_results,
342 finish_reason=normalize_finish_reason(payload.stop_reason),
343 usage=self._usage_from_wire(payload.usage),
344 passthrough=passthrough,
345 )
346 )
347 except (RelayError, ValueError, TypeError, KeyError) as exc:
348 return Err(translate(exc, detail="response_to_ir"))
350 def ir_to_response(
351 self, response: RelayResponse, *, context: ConversionContext
352 ) -> Result[Any, RelayError]:
353 """Convert canonical ``RelayResponse`` into a ``ClaudeResponse``.
355 Args:
356 response: Canonical response IR.
357 context: Per-conversion context with loss sink.
359 Returns:
360 Ok(response) on success, Err(relay_error) on failure.
361 """
362 try:
363 passthrough = dict(response.passthrough)
364 stop_sequence = passthrough.pop("stop_sequence", None)
365 blocks: list[ClaudeContent] = []
366 if response.content:
367 blocks.append(ClaudeContent(type="text", text=response.content))
368 for tool_call in response.tool_calls:
369 blocks.append(_tool_call_to_block(tool_call))
370 stop_reason: str | None = None
371 if response.tool_calls:
372 stop_reason = "tool_use"
373 elif stop_sequence is not None:
374 stop_reason = "stop_sequence"
375 elif response.finish_reason is not None:
376 stop_reason = self._stop_reason_from_ir(response.finish_reason, context)
377 return Ok(
378 ClaudeResponse(
379 id=response.id or f"chatcmpl-{new_uuid()}",
380 model=context.resolve_model(response.model),
381 content=blocks,
382 stop_reason=stop_reason,
383 stop_sequence=stop_sequence
384 if stop_reason == "stop_sequence"
385 else None,
386 usage=self._usage_to_wire(response.usage),
387 passthrough=passthrough,
388 )
389 )
390 except (RelayError, ValueError, TypeError, KeyError) as exc:
391 return Err(translate(exc, detail="ir_to_response"))
393 def stream_to_delta(
394 self, event: Any, *, state: StreamState
395 ) -> Result[tuple[StreamDelta, ...], RelayError]:
396 """Stream conversion is deferred to the shared stream lifecycle task."""
397 return Err(
398 unsupported_feature("claude stream conversion is not implemented yet")
399 )
401 def delta_to_stream(
402 self, delta: StreamDelta, *, state: StreamState
403 ) -> Result[tuple[Any, ...], RelayError]:
404 """Stream conversion is deferred to the shared stream lifecycle task."""
405 return Err(
406 unsupported_feature("claude stream conversion is not implemented yet")
407 )
409 # -- helpers -------------------------------------------------------------
411 def _message_to_ir(
412 self,
413 message: ClaudeMessage,
414 context: ConversionContext,
415 index: int,
416 tool_names: dict[str, str],
417 ) -> list[ChatMessage]:
418 """Convert one Claude message into one or more canonical messages."""
419 if message.role == "assistant":
420 assistant_message = self._assistant_to_ir(message)
421 for tool_call in assistant_message.tool_calls or []:
422 if tool_call.id and tool_call.function and tool_call.function.name:
423 tool_names[tool_call.id] = tool_call.function.name
424 return [assistant_message]
425 if message.role == "user":
426 return self._user_to_ir(message, context, index, tool_names)
427 record_loss(
428 context,
429 field=f"messages[{index}].role",
430 target=_TARGET,
431 reason="unknown_role_dropped",
432 )
433 return []
435 def _assistant_to_ir(self, message: ClaudeMessage) -> ChatMessage:
436 """Convert an assistant message, separating thinking/tool blocks."""
437 content = message.content
438 if isinstance(content, str):
439 content = [ClaudeContent(type="text", text=content)]
440 text_parts: list[str] = []
441 thinking_blocks: list[dict[str, Any]] = []
442 tool_calls: list[ToolCall] = []
443 for block in content:
444 if block.type == "text":
445 if block.text is not None:
446 text_parts.append(block.text)
447 elif block.type == "thinking":
448 thinking_blocks.append(
449 {
450 "type": "thinking",
451 "thinking": block.thinking or "",
452 "signature": block.signature or "",
453 }
454 )
455 elif block.type == "tool_use":
456 tool_calls.append(_tool_call_from_block(block))
457 return ChatMessage(
458 role="assistant",
459 content="".join(text_parts),
460 tool_calls=tool_calls or None,
461 thinking_blocks=thinking_blocks or None,
462 )
464 def _user_to_ir(
465 self,
466 message: ClaudeMessage,
467 context: ConversionContext,
468 index: int,
469 tool_names: dict[str, str],
470 ) -> list[ChatMessage]:
471 """Convert a user message, unwrapping tool_result blocks."""
472 content = message.content
473 if isinstance(content, str):
474 content = [ClaudeContent(type="text", text=content)]
475 parts: list[ContentPart] = []
476 tool_results: list[ChatMessage] = []
477 has_tool_results = False
478 has_other = False
479 for block in content:
480 if block.type == "tool_result":
481 has_tool_results = True
482 result_text = "".join(
483 part.text or ""
484 for part in (block.tool_result_content or [])
485 if part.type == "text"
486 )
487 metadata: dict[str, Any] | None = dict(block.passthrough) or None
488 tool_results.append(
489 ChatMessage(
490 role="tool",
491 content=result_text,
492 tool_call_id=block.tool_use_id,
493 name=tool_names.get(block.tool_use_id or ""),
494 metadata=metadata,
495 )
496 )
497 elif block.type == "text":
498 has_other = True
499 if block.text is not None:
500 parts.append(TextPart(text=block.text))
501 elif block.type == "image":
502 has_other = True
503 image = self._image_to_part(block, context, index)
504 if image is not None:
505 parts.append(image)
506 else:
507 record_loss(
508 context,
509 field=f"messages[{index}].content.{block.type}",
510 target=_TARGET,
511 reason="unknown_block_dropped",
512 )
513 if has_tool_results and has_other:
514 record_loss(
515 context,
516 field=f"messages[{index}]",
517 target=_TARGET,
518 reason="mixed_user_content_reordered",
519 )
520 turns: list[ChatMessage] = []
521 if parts:
522 turns.append(
523 ChatMessage(
524 role="user",
525 content=(
526 parts[0].text
527 if len(parts) == 1 and isinstance(parts[0], TextPart)
528 else list(parts)
529 ),
530 )
531 )
532 turns.extend(tool_results)
533 if not turns:
534 record_loss(
535 context,
536 field=f"messages[{index}]",
537 target=_TARGET,
538 reason="empty_message_dropped",
539 )
540 return turns
542 @staticmethod
543 def _image_to_part(
544 block: ClaudeContent, context: ConversionContext, index: int
545 ) -> ContentPart | None:
546 """Convert a Claude image block into a canonical image part."""
547 source = block.image_source
548 if not isinstance(source, dict):
549 record_loss(
550 context,
551 field=f"messages[{index}].image",
552 target=_TARGET,
553 reason="missing_source",
554 )
555 return None
556 source_type = source.get("type")
557 if source_type == "base64":
558 return ImageBase64Part(
559 data=str(source.get("data", "")),
560 media_type=str(source.get("media_type", "")),
561 )
562 if source_type == "url":
563 return ImageUrlPart(url=str(source.get("url", "")))
564 record_loss(
565 context,
566 field=f"messages[{index}].image",
567 target=_TARGET,
568 reason="unknown_source_type",
569 )
570 return None
572 @staticmethod
573 def _system_to_ir(
574 system: str | list[dict[str, Any]] | None, context: ConversionContext
575 ) -> str | None:
576 """Normalize the Claude ``system`` field into canonical system text."""
577 if system is None:
578 return None
579 if isinstance(system, str):
580 return system
581 texts: list[str] = []
582 for block in system:
583 if isinstance(block, dict) and block.get("type") == "text":
584 texts.append(str(block.get("text", "")))
585 else:
586 record_loss(
587 context,
588 field="system",
589 target=_TARGET,
590 reason="non_text_system_block_dropped",
591 )
592 return "\n".join(texts)
594 @staticmethod
595 def _tools_to_ir(
596 tools: list[dict[str, Any]] | None, context: ConversionContext
597 ) -> list[ToolDefinition]:
598 """Convert Claude wire tools into canonical ``ToolDefinition`` objects."""
599 definitions: list[ToolDefinition] = []
600 for index, tool in enumerate(tools or []):
601 if not isinstance(tool, dict):
602 record_loss(
603 context,
604 field=f"tools[{index}]",
605 target=_TARGET,
606 reason="non_dict_tool_dropped",
607 )
608 continue
609 schema = tool.get("input_schema", {})
610 definitions.append(
611 ToolDefinition(
612 name=str(tool.get("name", "")),
613 description=str(tool.get("description", "")),
614 parameters=schema if isinstance(schema, dict) else {},
615 )
616 )
617 return definitions
619 def _message_from_ir(
620 self, message: ChatMessage, context: ConversionContext
621 ) -> Result[ClaudeMessage, RelayError]:
622 """Convert one canonical message into a Claude message."""
623 if message.role == "tool":
624 return Ok(
625 ClaudeMessage(
626 role="user",
627 content=[
628 ClaudeContent(
629 type="tool_result",
630 tool_use_id=message.tool_call_id,
631 tool_result_content=[
632 ClaudeContent(
633 type="text",
634 text=self._text_from_content(message.content),
635 )
636 ],
637 passthrough=dict(message.metadata or {}),
638 )
639 ],
640 )
641 )
642 if message.role == "assistant":
643 blocks: list[ClaudeContent] = []
644 for block in message.thinking_blocks or []:
645 if isinstance(block, dict) and block.get("type") == "thinking":
646 blocks.append(
647 ClaudeContent(
648 type="thinking",
649 thinking=str(block.get("thinking", "")),
650 signature=(
651 str(block["signature"])
652 if block.get("signature")
653 else None
654 ),
655 )
656 )
657 content_blocks = self._content_to_blocks(message.content, context)
658 if content_blocks.is_err():
659 return Err(content_blocks.unwrap_err())
660 wire_blocks = content_blocks.unwrap()
661 has_text = any(block.type == "text" and block.text for block in wire_blocks)
662 if not has_text:
663 pure_tool_turn = bool(
664 (message.metadata or {}).get("function_call_item_ids")
665 )
666 if (
667 message.tool_calls and not pure_tool_turn
668 ) or not message.tool_calls:
669 wire_blocks = [ClaudeContent(type="text", text="...")]
670 else:
671 wire_blocks = []
672 blocks.extend(wire_blocks)
673 for tool_call in message.tool_calls or []:
674 blocks.append(_tool_call_to_block(tool_call))
675 return Ok(
676 ClaudeMessage(role="assistant", content=self._collapse_content(blocks))
677 )
678 if message.role == "user":
679 if isinstance(message.content, list) and any(
680 isinstance(part, ImageBase64Part) for part in message.content
681 ):
682 return Ok(ClaudeMessage(role="user", content=[]))
683 user_blocks = self._content_to_blocks(message.content, context)
684 if user_blocks.is_err():
685 return Err(user_blocks.unwrap_err())
686 return Ok(
687 ClaudeMessage(
688 role="user", content=self._collapse_content(user_blocks.unwrap())
689 )
690 )
691 record_loss(
692 context,
693 field="messages",
694 target=_TARGET,
695 reason=f"unknown_role_{message.role}_dropped",
696 )
697 return Ok(
698 ClaudeMessage(role="user", content=[ClaudeContent(type="text", text="")])
699 )
701 def _content_to_blocks(
702 self, content: str | list[ContentPart], context: ConversionContext
703 ) -> Result[list[ClaudeContent], RelayError]:
704 """Convert canonical content into a Claude block list."""
705 if isinstance(content, str):
706 return Ok([ClaudeContent(type="text", text=content)])
707 blocks: list[ClaudeContent] = []
708 for part in content:
709 if isinstance(part, TextPart):
710 blocks.append(ClaudeContent(type="text", text=part.text))
711 elif isinstance(part, ImageBase64Part):
712 blocks.append(
713 ClaudeContent(
714 type="image",
715 image_source={
716 "type": "base64",
717 "media_type": part.media_type,
718 "data": part.data,
719 },
720 )
721 )
722 elif isinstance(part, ImageUrlPart):
723 resolved = self._resolve_image(part, context)
724 if resolved.is_err():
725 return Err(resolved.unwrap_err())
726 blocks.append(
727 ClaudeContent(
728 type="image",
729 image_source={
730 "type": "base64",
731 "media_type": resolved.unwrap()[0],
732 "data": resolved.unwrap()[1],
733 },
734 )
735 )
736 else:
737 record_loss(
738 context,
739 field="message.content",
740 target=_TARGET,
741 reason="unknown_content_part",
742 )
743 if not blocks:
744 blocks.append(ClaudeContent(type="text", text=""))
745 return Ok(blocks)
747 @staticmethod
748 def _collapse_content(
749 blocks: list[ClaudeContent],
750 ) -> str | list[ClaudeContent]:
751 """Collapse a single text block into plain string content.
753 The Claude wire protocol accepts either a plain string or a block
754 list for message content; relaykit emits plain strings for
755 single-text messages.
756 """
757 if len(blocks) == 1 and blocks[0].type == "text" and blocks[0].text is not None:
758 return blocks[0].text
759 return blocks
761 @staticmethod
762 def _resolve_image(
763 part: ImageUrlPart, context: ConversionContext
764 ) -> Result[tuple[str, str], RelayError]:
765 """Resolve a URL or data-URI image for Claude.
767 Data URIs decode locally; URLs go through the context resolver.
768 """
769 resolved = resolve_media(
770 part.url,
771 context,
772 field="message.content",
773 target=_TARGET,
774 lossy=False,
775 )
776 if resolved.is_err():
777 return Err(resolved.unwrap_err())
778 image = resolved.unwrap()
779 assert image is not None # lossy=False never drops media
780 return Ok(image)
782 @staticmethod
783 def _text_from_content(content: str | list[ContentPart]) -> str:
784 """Extract plain text from canonical content."""
785 if isinstance(content, str):
786 return content
787 return "".join(part.text for part in content if isinstance(part, TextPart))
789 @staticmethod
790 def _tool_from_ir(tool: ToolDefinition) -> dict[str, Any]:
791 """Serialize a canonical ``ToolDefinition`` as a Claude wire tool."""
792 return {
793 "name": tool.name,
794 "description": tool.description,
795 "input_schema": tool.parameters,
796 }
798 def _thinking_from_ir(
799 self, request: RelayRequest, context: ConversionContext
800 ) -> dict[str, Any] | None:
801 """Rebuild the Claude ``thinking`` dict from canonical thinking."""
802 thinking = request.thinking
803 if thinking is None:
804 return None
805 if thinking.effort is not None:
806 record_loss(
807 context,
808 field="thinking",
809 target=_TARGET,
810 reason="effort_not_supported",
811 )
812 return None
813 if thinking.suppress:
814 return {"type": "disabled"}
815 return {"type": "enabled", "budget_tokens": thinking.budget_tokens}
817 @staticmethod
818 def _usage_from_wire(usage: ClaudeUsage | None) -> RelayUsage | None:
819 """Map a wire ``ClaudeUsage`` into canonical ``RelayUsage``.
821 Mirrors relaykit's ``buildOpenAIStyleUsageFromClaudeUsage``: the
822 prompt count includes cache reads and cache creations, and the
823 chat ``input_tokens`` is stamped with that total.
824 """
825 if usage is None:
826 return None
827 prompt = (
828 usage.input_tokens
829 + usage.cache_read_input_tokens
830 + usage.cache_creation_input_tokens
831 )
832 return RelayUsage(
833 prompt_tokens=prompt,
834 completion_tokens=usage.output_tokens,
835 cache_read_tokens=usage.cache_read_input_tokens,
836 cache_creation_tokens=usage.cache_creation_input_tokens,
837 input_tokens=prompt,
838 )
840 @staticmethod
841 def _usage_to_wire(usage: RelayUsage | None) -> ClaudeUsage | None:
842 """Serialize canonical ``RelayUsage`` into a ``ClaudeUsage``."""
843 if usage is None:
844 return None
845 return ClaudeUsage(
846 input_tokens=usage.prompt_tokens,
847 output_tokens=usage.completion_tokens,
848 cache_read_input_tokens=usage.cache_read_tokens,
849 cache_creation_input_tokens=usage.cache_creation_tokens,
850 )
852 @staticmethod
853 def _stop_reason_from_ir(
854 finish_reason: str | None, context: ConversionContext
855 ) -> str | None:
856 """Map a canonical finish reason back to a Claude stop reason."""
857 if finish_reason is None:
858 return None
859 if finish_reason in {"function_call", "content_filter"}:
860 record_loss(
861 context,
862 field="finish_reason",
863 target=_TARGET,
864 reason=f"{finish_reason}_adapted",
865 )
866 elif finish_reason not in FINISH_REASON_TO_WIRE:
867 record_loss(
868 context,
869 field="finish_reason",
870 target=_TARGET,
871 reason="finish_reason_adapted",
872 )
873 return finish_reason_to_wire(finish_reason, _TARGET)