Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-relay/src/lexigram/ai/relay/mappers/claude/request.py: 15%

175 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +0800

1"""Request-direction conversion for the Claude mapper.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import replace 

6from typing import TYPE_CHECKING, Any 

7 

8from lexigram.ai.relay.context import ConversionContext 

9from lexigram.ai.relay.errors import ( 

10 missing_required_option, 

11 translate, 

12 unsupported_format, 

13) 

14from lexigram.ai.relay.mappers.base import record_loss 

15from lexigram.ai.relay.mappers.claude.utils import ( 

16 _TARGET, 

17 _tool_call_from_block, 

18) 

19from lexigram.contracts.ai.agents import ToolDefinition 

20from lexigram.contracts.ai.exceptions import RelayError 

21from lexigram.contracts.ai.llm import ChatMessage, ToolCall 

22from lexigram.contracts.ai.multimodal import ( 

23 ContentPart, 

24 ImageBase64Part, 

25 ImageUrlPart, 

26 TextPart, 

27) 

28from lexigram.contracts.ai.relay.dto import ClaudeContent, ClaudeMessage, ClaudeRequest 

29from lexigram.contracts.ai.relay.ir import RelayRequest 

30from lexigram.contracts.ai.thinking import ThinkingConfig 

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

32 

33if TYPE_CHECKING: 

34 from lexigram.ai.relay.mappers.claude import ClaudeMapper 

35 

36 

37class RequestMixin: 

38 """Request conversion: wire ``ClaudeRequest`` to IR and back.""" 

39 

40 def request_to_ir( 

41 self: ClaudeMapper, payload: Any, *, context: ConversionContext 

42 ) -> Result[RelayRequest, RelayError]: 

43 """Convert a ``ClaudeRequest`` into canonical ``RelayRequest``. 

44 

45 Args: 

46 payload: A wire request DTO. 

47 context: Per-conversion context with loss sink. 

48 

49 Returns: 

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

51 """ 

52 if not isinstance(payload, ClaudeRequest): 

53 return Err( 

54 unsupported_format( 

55 f"expected ClaudeRequest, got {type(payload).__name__}" 

56 ) 

57 ) 

58 try: 

59 tool_names: dict[str, str] = {} 

60 messages: list[ChatMessage] = [] 

61 for index, message in enumerate(payload.messages): 

62 messages.extend( 

63 self._message_to_ir(message, context, index, tool_names) 

64 ) 

65 thinking: ThinkingConfig | None = None 

66 if isinstance(payload.thinking, dict): 

67 if payload.thinking.get("type") == "enabled": 

68 thinking = ThinkingConfig( 

69 budget_tokens=int(payload.thinking.get("budget_tokens", 0) or 0) 

70 ) 

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

72 if payload.metadata is not None: 

73 metadata["metadata"] = payload.metadata 

74 return Ok( 

75 RelayRequest( 

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

77 messages=messages, 

78 system=self._system_to_ir(payload.system, context), 

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

80 tool_choice=payload.tool_choice, 

81 temperature=payload.temperature, 

82 top_p=payload.top_p, 

83 max_tokens=payload.max_tokens, 

84 stop_sequences=list(payload.stop_sequences or []), 

85 stream=payload.stream, 

86 thinking=thinking, 

87 metadata=metadata, 

88 passthrough=dict(payload.passthrough), 

89 ) 

90 ) 

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

92 return Err(translate(exc, detail="request_to_ir")) 

93 

94 def ir_to_request( 

95 self: ClaudeMapper, request: RelayRequest, *, context: ConversionContext 

96 ) -> Result[Any, RelayError]: 

97 """Convert canonical ``RelayRequest`` into a ``ClaudeRequest``. 

98 

99 Args: 

100 request: Canonical request IR. 

101 context: Per-conversion context with loss sink. 

102 

103 Returns: 

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

105 """ 

106 max_tokens = request.max_tokens 

107 if max_tokens is None: 

108 max_tokens = context.max_tokens_for(request.model) 

109 if max_tokens is None: 

110 return Err(missing_required_option("claude requires max_tokens")) 

111 model = request.model 

112 temperature = request.temperature 

113 top_p = request.top_p 

114 thinking = self._thinking_from_ir(request, context) 

115 claude_options = context.options.claude 

116 if claude_options.thinking_adapter_enabled and model.endswith("-thinking"): 

117 if ( 

118 claude_options.minimum_max_tokens > 0 

119 and max_tokens < claude_options.minimum_max_tokens 

120 ): 

121 max_tokens = claude_options.minimum_max_tokens 

122 record_loss( 

123 context, 

124 field="max_tokens", 

125 target=_TARGET, 

126 reason="max_tokens_floored", 

127 ) 

128 if thinking is None and claude_options.thinking_budget_percentage > 0: 

129 thinking = { 

130 "type": "enabled", 

131 "budget_tokens": int( 

132 max_tokens * claude_options.thinking_budget_percentage / 100 

133 ), 

134 } 

135 temperature = 1.0 

136 top_p = None 

137 if ( 

138 not context.preserve_thinking_suffix(model) 

139 and not context.options.model_suffix_preserved 

140 ): 

141 model = model[: -len("-thinking")] 

142 try: 

143 messages: list[ClaudeMessage] = [] 

144 system_parts: list[str] = [] 

145 if request.system: 

146 system_parts.append(request.system) 

147 for message in request.messages: 

148 if message.role == "system": 

149 system_parts.append(self._text_from_content(message.content)) 

150 continue 

151 prepared = message 

152 if message.role == "assistant" and message.tool_calls: 

153 if any(not tool_call.id for tool_call in message.tool_calls): 

154 prepared = replace( 

155 message, 

156 tool_calls=[ 

157 tool_call 

158 if tool_call.id 

159 else replace(tool_call, id=f"call_{index + 1}") 

160 for index, tool_call in enumerate(message.tool_calls) 

161 ], 

162 ) 

163 elif message.role == "tool" and not message.tool_call_id: 

164 prepared = replace(message, tool_call_id="call_0") 

165 claude_message = self._message_from_ir(prepared, context) 

166 if claude_message.is_err(): 

167 return claude_message 

168 messages.append(claude_message.unwrap()) 

169 tool_choice = request.tool_choice 

170 if isinstance(tool_choice, str): 

171 tool_choice = {"type": tool_choice} 

172 return Ok( 

173 ClaudeRequest( 

174 model=context.resolve_model(model), 

175 max_tokens=max_tokens, 

176 messages=messages, 

177 system=( 

178 [{"type": "text", "text": "\n".join(system_parts)}] 

179 if system_parts 

180 else None 

181 ), 

182 temperature=temperature, 

183 top_p=top_p, 

184 stream=request.stream, 

185 tools=( 

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

187 if request.tools 

188 else None 

189 ), 

190 tool_choice=tool_choice, 

191 stop_sequences=list(request.stop_sequences) or None, 

192 thinking=thinking, 

193 metadata=( 

194 request.metadata.get("metadata") 

195 if isinstance(request.metadata.get("metadata"), dict) 

196 else None 

197 ), 

198 passthrough={ 

199 **request.passthrough, 

200 **{ 

201 key: value 

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

203 if key 

204 not in { 

205 "metadata", 

206 "max_tokens_kind", 

207 "generation_config", 

208 "safety_settings", 

209 "tool_config", 

210 "reasoning", 

211 "stream_options", 

212 "service_tier", 

213 } 

214 }, 

215 }, 

216 ) 

217 ) 

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

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

220 

221 def _message_to_ir( 

222 self: ClaudeMapper, 

223 message: ClaudeMessage, 

224 context: ConversionContext, 

225 index: int, 

226 tool_names: dict[str, str], 

227 ) -> list[ChatMessage]: 

228 """Convert one Claude message into one or more canonical messages.""" 

229 if message.role == "assistant": 

230 assistant_message = self._assistant_to_ir(message) 

231 for tool_call in assistant_message.tool_calls or []: 

232 if tool_call.id and tool_call.function and tool_call.function.name: 

233 tool_names[tool_call.id] = tool_call.function.name 

234 return [assistant_message] 

235 if message.role == "user": 

236 return self._user_to_ir(message, context, index, tool_names) 

237 record_loss( 

238 context, 

239 field=f"messages[{index}].role", 

240 target=_TARGET, 

241 reason="unknown_role_dropped", 

242 ) 

243 return [] 

244 

245 def _assistant_to_ir(self: ClaudeMapper, message: ClaudeMessage) -> ChatMessage: 

246 """Convert an assistant message, separating thinking/tool blocks.""" 

247 content = message.content 

248 if isinstance(content, str): 

249 content = [ClaudeContent(type="text", text=content)] 

250 text_parts: list[str] = [] 

251 thinking_blocks: list[dict[str, Any]] = [] 

252 tool_calls: list[ToolCall] = [] 

253 for block in content: 

254 if block.type == "text": 

255 if block.text is not None: 

256 text_parts.append(block.text) 

257 elif block.type == "thinking": 

258 thinking_blocks.append( 

259 { 

260 "type": "thinking", 

261 "thinking": block.thinking or "", 

262 "signature": block.signature or "", 

263 } 

264 ) 

265 elif block.type == "tool_use": 

266 tool_calls.append(_tool_call_from_block(block)) 

267 return ChatMessage( 

268 role="assistant", 

269 content="".join(text_parts), 

270 tool_calls=tool_calls or None, 

271 thinking_blocks=thinking_blocks or None, 

272 ) 

273 

274 def _user_to_ir( 

275 self: ClaudeMapper, 

276 message: ClaudeMessage, 

277 context: ConversionContext, 

278 index: int, 

279 tool_names: dict[str, str], 

280 ) -> list[ChatMessage]: 

281 """Convert a user message, unwrapping tool_result blocks.""" 

282 content = message.content 

283 if isinstance(content, str): 

284 content = [ClaudeContent(type="text", text=content)] 

285 parts: list[ContentPart] = [] 

286 tool_results: list[ChatMessage] = [] 

287 has_tool_results = False 

288 has_other = False 

289 for block in content: 

290 if block.type == "tool_result": 

291 has_tool_results = True 

292 result_text = "".join( 

293 part.text or "" 

294 for part in (block.tool_result_content or []) 

295 if part.type == "text" 

296 ) 

297 metadata: dict[str, Any] | None = dict(block.passthrough) or None 

298 tool_results.append( 

299 ChatMessage( 

300 role="tool", 

301 content=result_text, 

302 tool_call_id=block.tool_use_id, 

303 name=tool_names.get(block.tool_use_id or ""), 

304 metadata=metadata, 

305 ) 

306 ) 

307 elif block.type == "text": 

308 has_other = True 

309 if block.text is not None: 

310 parts.append(TextPart(text=block.text)) 

311 elif block.type == "image": 

312 has_other = True 

313 image = self._image_to_part(block, context, index) 

314 if image is not None: 

315 parts.append(image) 

316 else: 

317 record_loss( 

318 context, 

319 field=f"messages[{index}].content.{block.type}", 

320 target=_TARGET, 

321 reason="unknown_block_dropped", 

322 ) 

323 if has_tool_results and has_other: 

324 record_loss( 

325 context, 

326 field=f"messages[{index}]", 

327 target=_TARGET, 

328 reason="mixed_user_content_reordered", 

329 ) 

330 turns: list[ChatMessage] = [] 

331 if parts: 

332 turns.append( 

333 ChatMessage( 

334 role="user", 

335 content=( 

336 parts[0].text 

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

338 else list(parts) 

339 ), 

340 ) 

341 ) 

342 turns.extend(tool_results) 

343 if not turns: 

344 record_loss( 

345 context, 

346 field=f"messages[{index}]", 

347 target=_TARGET, 

348 reason="empty_message_dropped", 

349 ) 

350 return turns 

351 

352 @staticmethod 

353 def _image_to_part( 

354 block: ClaudeContent, context: ConversionContext, index: int 

355 ) -> ContentPart | None: 

356 """Convert a Claude image block into a canonical image part.""" 

357 source = block.image_source 

358 if not isinstance(source, dict): 

359 record_loss( 

360 context, 

361 field=f"messages[{index}].image", 

362 target=_TARGET, 

363 reason="missing_source", 

364 ) 

365 return None 

366 source_type = source.get("type") 

367 if source_type == "base64": 

368 return ImageBase64Part( 

369 data=str(source.get("data", "")), 

370 media_type=str(source.get("media_type", "")), 

371 ) 

372 if source_type == "url": 

373 return ImageUrlPart(url=str(source.get("url", ""))) 

374 record_loss( 

375 context, 

376 field=f"messages[{index}].image", 

377 target=_TARGET, 

378 reason="unknown_source_type", 

379 ) 

380 return None 

381 

382 @staticmethod 

383 def _system_to_ir( 

384 system: str | list[dict[str, Any]] | None, context: ConversionContext 

385 ) -> str | None: 

386 """Normalize the Claude ``system`` field into canonical system text.""" 

387 if system is None: 

388 return None 

389 if isinstance(system, str): 

390 return system 

391 texts: list[str] = [] 

392 for block in system: 

393 if isinstance(block, dict) and block.get("type") == "text": 

394 texts.append(str(block.get("text", ""))) 

395 else: 

396 record_loss( 

397 context, 

398 field="system", 

399 target=_TARGET, 

400 reason="non_text_system_block_dropped", 

401 ) 

402 return "\n".join(texts) 

403 

404 @staticmethod 

405 def _tools_to_ir( 

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

407 ) -> list[ToolDefinition]: 

408 """Convert Claude wire tools into canonical ``ToolDefinition`` objects.""" 

409 definitions: list[ToolDefinition] = [] 

410 for index, tool in enumerate(tools or []): 

411 if not isinstance(tool, dict): 

412 record_loss( 

413 context, 

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

415 target=_TARGET, 

416 reason="non_dict_tool_dropped", 

417 ) 

418 continue 

419 schema = tool.get("input_schema", {}) 

420 definitions.append( 

421 ToolDefinition( 

422 name=str(tool.get("name", "")), 

423 description=str(tool.get("description", "")), 

424 parameters=schema if isinstance(schema, dict) else {}, 

425 ) 

426 ) 

427 return definitions