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

210 statements  

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

1"""Canonical relay IR → Gemini wire request conversion.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import replace 

6from typing import Any 

7 

8from lexigram.ai.relay.context import ConversionContext 

9from lexigram.ai.relay.errors import translate 

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

11from lexigram.ai.relay.mappers.gemini_request._shared import ( 

12 _MIME_KEY, 

13 _SAFETY_CATEGORIES, 

14 _SCHEMA_TYPE_MAP, 

15 _TARGET, 

16 _THOUGHT_SIGNATURE_BYPASS, 

17 _tool_call_to_part, 

18) 

19from lexigram.ai.relay.media import resolve_media 

20from lexigram.contracts.ai.agents import ToolDefinition 

21from lexigram.contracts.ai.exceptions import RelayError 

22from lexigram.contracts.ai.llm import ChatMessage 

23from lexigram.contracts.ai.multimodal import ( 

24 ContentPart, 

25 ImageBase64Part, 

26 ImageUrlPart, 

27 TextPart, 

28) 

29from lexigram.contracts.ai.relay.dto import GeminiContent, GeminiPart, GeminiRequest 

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

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

32from lexigram.serialization import loads_str 

33 

34 

35def ir_to_request( 

36 request: RelayRequest, *, context: ConversionContext 

37) -> Result[Any, RelayError]: 

38 """Convert canonical ``RelayRequest`` into a ``GeminiRequest``. 

39 

40 Args: 

41 request: Canonical request IR. 

42 context: Per-conversion context with loss sink. 

43 

44 Returns: 

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

46 """ 

47 try: 

48 system_parts: list[str] = [] 

49 contents: list[GeminiContent] = [] 

50 tool_names, tool_names_by_id = _tool_name_resolver(request) 

51 for message in request.messages: 

52 if message.role == "system": 

53 system_parts.append(_text_from_content(message.content)) 

54 continue 

55 content = _content_from_ir( 

56 message, request.model, tool_names, tool_names_by_id, context 

57 ) 

58 if content.is_err(): 

59 return content 

60 contents.append(content.unwrap()) 

61 if request.system: 

62 system_parts.append(request.system) 

63 return Ok( 

64 GeminiRequest( 

65 contents=contents, 

66 system_instruction=( 

67 {"parts": [{"text": text} for text in system_parts]} 

68 if system_parts 

69 else None 

70 ), 

71 generation_config=_generation_config_from_ir(request, context), 

72 safety_settings=_safety_settings_from_ir(request, context), 

73 tools=_tools_from_ir(request.tools), 

74 tool_config=_tool_config_from_ir(request), 

75 passthrough=_request_passthrough(request), 

76 ) 

77 ) 

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

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

80 

81 

82def _content_from_ir( 

83 message: ChatMessage, 

84 model: str, 

85 tool_names: list[str], 

86 tool_names_by_id: dict[str, str], 

87 context: ConversionContext, 

88) -> Result[GeminiContent, RelayError]: 

89 """Convert one canonical message into a Gemini content turn. 

90 

91 Args: 

92 message: Canonical message to serialize. 

93 model: The selected model, for capability lookups. 

94 tool_names: Positional resolver for tool-message function 

95 names when the canonical ``tool_call_id`` carries no 

96 stable link. 

97 tool_names_by_id: Resolver keyed by canonical tool-call id. 

98 context: Per-conversion context with loss sink. 

99 

100 Returns: 

101 Ok(content) on success, Err(relay_error) on failure. 

102 """ 

103 if message.role == "tool": 

104 response: Any = message.content 

105 if isinstance(response, list): 

106 response = _text_from_content(message.content) 

107 if isinstance(response, str): 

108 try: 

109 response = loads_str(response) 

110 except ValueError: 

111 pass 

112 name = tool_names_by_id.get(message.tool_call_id or "", "") 

113 if not name and tool_names: 

114 name = tool_names.pop(0) 

115 if isinstance(response, str): 

116 response = {"content": response} 

117 return Ok( 

118 GeminiContent( 

119 role="user", 

120 parts=[ 

121 GeminiPart( 

122 function_response={ 

123 "name": name, 

124 "response": response, 

125 } 

126 ) 

127 ], 

128 ) 

129 ) 

130 if message.role == "assistant": 

131 parts = _assistant_parts_from_ir(message, model, context) 

132 if parts.is_err(): 

133 return Err(parts.unwrap_err()) 

134 return Ok(GeminiContent(role="model", parts=parts.unwrap())) 

135 if message.role == "user": 

136 parts = _user_parts_from_ir(message.content, context) 

137 if parts.is_err(): 

138 return Err(parts.unwrap_err()) 

139 return Ok(GeminiContent(role="user", parts=parts.unwrap())) 

140 record_loss( 

141 context, 

142 field="messages", 

143 target=_TARGET, 

144 reason=f"unknown_role_{message.role}_dropped", 

145 ) 

146 return Ok(GeminiContent(role="user", parts=[GeminiPart(text="")])) 

147 

148 

149def _tool_name_resolver( 

150 request: RelayRequest, 

151) -> tuple[list[str], dict[str, str]]: 

152 """Return tool-message name resolvers for a request. 

153 

154 Gemini ``functionResponse`` blocks name the function, not the 

155 call id. Tool messages resolve their function name by canonical 

156 ``tool_call_id`` first; unresolved messages fall back to 

157 positional order against the assistant tool calls that preceded 

158 them. 

159 """ 

160 names: list[str] = [] 

161 names_by_id: dict[str, str] = {} 

162 for message in request.messages: 

163 if message.role == "assistant": 

164 for tool_call in message.tool_calls or []: 

165 if tool_call.function: 

166 names.append(tool_call.function.name) 

167 if tool_call.id: 

168 names_by_id[tool_call.id] = tool_call.function.name 

169 return names, names_by_id 

170 

171 

172def _assistant_parts_from_ir( 

173 message: ChatMessage, model: str, context: ConversionContext 

174) -> Result[list[GeminiPart], RelayError]: 

175 """Rebuild Gemini model parts from an assistant message. 

176 

177 When the thought-signature bypass policy is enabled the thinking 

178 blocks are folded away and a bypass ``thoughtSignature`` is 

179 attached to the first function-call part (relaykit's 

180 ``FunctionCallThoughtSignatureEnabled`` behavior). Otherwise 

181 thinking blocks are re-emitted as native Gemini thought parts. 

182 """ 

183 attach_signature = context.options.gemini.thought_signature_bypass 

184 parts: list[GeminiPart] = [] 

185 if not attach_signature: 

186 for block in message.thinking_blocks or []: 

187 if not isinstance(block, dict): 

188 continue 

189 signature = block.get("thoughtSignature") 

190 parts.append( 

191 GeminiPart( 

192 text=str(block.get("text", "")), 

193 thought=True, 

194 thought_signature=str(signature) if signature else None, 

195 ) 

196 ) 

197 content_parts = _user_parts_from_ir(message.content, context) 

198 if content_parts.is_err(): 

199 return Err(content_parts.unwrap_err()) 

200 parts.extend(content_parts.unwrap()) 

201 for tool_call in message.tool_calls or []: 

202 parts.append(_tool_call_to_part(tool_call)) 

203 if attach_signature: 

204 parts = _attach_thought_signature(parts) 

205 return Ok(parts) 

206 

207 

208def _attach_thought_signature(parts: list[GeminiPart]) -> list[GeminiPart]: 

209 """Attach the relaykit thought-signature bypass value to model parts. 

210 

211 The signature lands on the first function-call part, or on the 

212 first non-empty text part when the message carries no tool calls. 

213 """ 

214 rebuilt: list[GeminiPart] = [] 

215 attached = False 

216 for part in parts: 

217 current = part 

218 if not attached and part.function_call is not None: 

219 current = replace(part, thought_signature=_THOUGHT_SIGNATURE_BYPASS) 

220 attached = True 

221 rebuilt.append(current) 

222 if not attached: 

223 for index, part in enumerate(rebuilt): 

224 if part.text: 

225 rebuilt[index] = replace( 

226 part, thought_signature=_THOUGHT_SIGNATURE_BYPASS 

227 ) 

228 break 

229 return rebuilt 

230 

231 

232def _user_parts_from_ir( 

233 content: str | list[ContentPart], context: ConversionContext 

234) -> Result[list[GeminiPart], RelayError]: 

235 """Convert canonical content into Gemini parts.""" 

236 if isinstance(content, str): 

237 return Ok([GeminiPart(text=content)] if content else []) 

238 parts: list[GeminiPart] = [] 

239 for part in content: 

240 if isinstance(part, TextPart): 

241 parts.append(GeminiPart(text=part.text)) 

242 elif isinstance(part, ImageBase64Part): 

243 parts.append( 

244 GeminiPart( 

245 inline_data={ 

246 _MIME_KEY: part.media_type, 

247 "data": part.data, 

248 } 

249 ) 

250 ) 

251 elif isinstance(part, ImageUrlPart): 

252 resolved = _resolve_image(part, context) 

253 if resolved.is_err(): 

254 return Err(resolved.unwrap_err()) 

255 media_type, data = resolved.unwrap() 

256 parts.append(GeminiPart(inline_data={_MIME_KEY: media_type, "data": data})) 

257 else: 

258 record_loss( 

259 context, 

260 field="message.content", 

261 target=_TARGET, 

262 reason="unknown_content_part", 

263 ) 

264 return Ok(parts) 

265 

266 

267def _resolve_image( 

268 part: ImageUrlPart, context: ConversionContext 

269) -> Result[tuple[str, str], RelayError]: 

270 """Resolve a URL or data-URI image for Gemini. 

271 

272 Data URIs decode locally; URLs go through the context resolver. 

273 """ 

274 resolved = resolve_media( 

275 part.url, 

276 context, 

277 field="message.content", 

278 target=_TARGET, 

279 lossy=False, 

280 ) 

281 if resolved.is_err(): 

282 return Err(resolved.unwrap_err()) 

283 image = resolved.unwrap() 

284 assert image is not None # lossy=False never drops media # noqa: S101 

285 return Ok(image) 

286 

287 

288def _text_from_content(content: str | list[ContentPart]) -> str: 

289 """Extract plain text from canonical content.""" 

290 if isinstance(content, str): 

291 return content 

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

293 

294 

295def _generation_config_from_ir( 

296 request: RelayRequest, context: ConversionContext 

297) -> dict[str, Any]: 

298 """Rebuild ``generationConfig`` from protocol metadata and canonical fields.""" 

299 raw = request.metadata.get("generation_config") 

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

301 for key in ( 

302 "temperature", 

303 "topP", 

304 "topK", 

305 "maxOutputTokens", 

306 "stopSequences", 

307 "responseMimeType", 

308 "responseSchema", 

309 "thinkingConfig", 

310 ): 

311 config.pop(key, None) 

312 if request.temperature is not None: 

313 config["temperature"] = request.temperature 

314 if request.top_p is not None: 

315 config["topP"] = request.top_p 

316 if request.top_k is not None: 

317 config["topK"] = request.top_k 

318 if request.max_tokens is not None: 

319 config["maxOutputTokens"] = request.max_tokens 

320 if request.stop_sequences: 

321 config["stopSequences"] = list(request.stop_sequences) 

322 if request.response_format is not None: 

323 if request.response_format.get("type") == "json_object": 

324 config["responseMimeType"] = "application/json" 

325 if isinstance(request.response_format.get("schema"), dict): 

326 config["responseSchema"] = request.response_format["schema"] 

327 thinking_config = _thinking_config_from_ir(request, context) 

328 if thinking_config is not None: 

329 config["thinkingConfig"] = thinking_config 

330 if "responseModalities" not in config and context.supports_image_generation( 

331 request.model 

332 ): 

333 config["responseModalities"] = ["TEXT", "IMAGE"] 

334 return config 

335 

336 

337def _thinking_config_from_ir( 

338 request: RelayRequest, context: ConversionContext 

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

340 """Build a Gemini ``thinkingConfig`` from canonical thinking.""" 

341 if request.thinking is not None: 

342 record_loss( 

343 context, 

344 field="thinking", 

345 target=_TARGET, 

346 reason="thinking_not_supported", 

347 ) 

348 if ( 

349 context.options.gemini.thinking_adapter_enabled 

350 and context.options.gemini.thinking_budget 

351 ): 

352 return {"thinkingBudget": context.options.gemini.thinking_budget} 

353 return None 

354 

355 

356def _safety_settings_from_ir( 

357 request: RelayRequest, context: ConversionContext 

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

359 """Rebuild Gemini safety settings from metadata or the callback.""" 

360 raw = request.metadata.get("safety_settings") 

361 if isinstance(raw, list): 

362 preserved = [dict(item) for item in raw if isinstance(item, dict)] 

363 return preserved or None 

364 collected: list[dict[str, Any]] = [] 

365 for category in _SAFETY_CATEGORIES: 

366 threshold = context.safety_setting(category) 

367 if threshold and isinstance(threshold, str): 

368 collected.append({"category": category, "threshold": threshold}) 

369 return collected or None 

370 

371 

372def _tools_from_ir(tools: list[ToolDefinition]) -> list[dict[str, Any]] | None: 

373 """Serialize canonical tools as Gemini function declarations.""" 

374 if not tools: 

375 return None 

376 return [ 

377 { 

378 "functionDeclarations": [ 

379 { 

380 "name": tool.name, 

381 "description": tool.description, 

382 "parameters": _upper_schema_types(tool.parameters), 

383 } 

384 for tool in tools 

385 ] 

386 } 

387 ] 

388 

389 

390def _request_passthrough(request: RelayRequest) -> dict[str, Any]: 

391 """Carry canonical passthrough state into the request wire payload.""" 

392 return dict(request.passthrough) 

393 

394 

395def _upper_schema_types(parameters: dict[str, Any]) -> dict[str, Any]: 

396 """Uppercase Gemini schema type markers recursively. 

397 

398 Gemini function declarations require ``STRING``/``OBJECT`` type 

399 values; canonical schemas carry the lowercase JSON-Schema form. 

400 """ 

401 out: dict[str, Any] = {} 

402 for key, value in parameters.items(): 

403 if key == "type" and isinstance(value, str): 

404 out[key] = _SCHEMA_TYPE_MAP.get(value, value) 

405 elif isinstance(value, dict): 

406 out[key] = _upper_schema_types(value) 

407 elif isinstance(value, list): 

408 out[key] = [ 

409 _upper_schema_types(item) if isinstance(item, dict) else item 

410 for item in value 

411 ] 

412 else: 

413 out[key] = value 

414 return out 

415 

416 

417def _tool_config_from_ir(request: RelayRequest) -> dict[str, Any] | None: 

418 """Rebuild a Gemini ``toolConfig`` from canonical tool choice.""" 

419 raw = request.metadata.get("tool_config") 

420 if isinstance(raw, dict): 

421 return dict(raw) 

422 choice = request.tool_choice 

423 if isinstance(choice, dict): 

424 name = choice.get("function", {}) 

425 if isinstance(name, dict): 

426 name = name.get("name") 

427 if isinstance(name, str) and name: 

428 return { 

429 "functionCallingConfig": { 

430 "mode": "ANY", 

431 "allowedFunctionNames": [name], 

432 } 

433 } 

434 return {"functionCallingConfig": {"mode": "ANY"}} 

435 if isinstance(choice, str): 

436 mode = {"auto": "AUTO", "none": "NONE", "required": "ANY"}.get(choice, "AUTO") 

437 return {"functionCallingConfig": {"mode": mode}} 

438 return None