Coverage for src / lexigram / ai / relay / mappers / gemini.py: 88%

485 statements  

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

1"""Google Gemini ``generateContent`` request and response mapper. 

2 

3Converts the Gemini wire DTOs (:class:`GeminiRequest` / 

4:class:`GeminiResponse`) into the canonical relay IR and back. Stream 

5conversion is handled by the shared stream lifecycle task and reports 

6``unsupported_feature`` until then. 

7""" 

8 

9from __future__ import annotations 

10 

11from dataclasses import replace 

12from typing import Any 

13 

14from lexigram.ai.relay.context import ConversionContext 

15from lexigram.ai.relay.errors import ( 

16 translate, 

17 unsupported_feature, 

18 unsupported_format, 

19) 

20from lexigram.ai.relay.finish_reasons import ( 

21 FINISH_REASON_TO_WIRE, 

22 finish_reason_to_wire, 

23) 

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

25from lexigram.ai.relay.media import resolve_media 

26from lexigram.contracts.ai.agents import ToolDefinition 

27from lexigram.contracts.ai.exceptions import RelayError 

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

29from lexigram.contracts.ai.multimodal import ( 

30 ContentPart, 

31 ImageBase64Part, 

32 ImageUrlPart, 

33 TextPart, 

34) 

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

36 GeminiCandidate, 

37 GeminiContent, 

38 GeminiGroundingMetadata, 

39 GeminiPart, 

40 GeminiPromptFeedback, 

41 GeminiRequest, 

42 GeminiResponse, 

43 GeminiSafetyRating, 

44 GeminiUsageMetadata, 

45) 

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

47 RelayRequest, 

48 RelayResponse, 

49 StreamDelta, 

50 StreamState, 

51 normalize_finish_reason, 

52) 

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

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

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

56from lexigram.serialization import dumps_str, loads_str 

57 

58__all__ = ["GeminiMapper"] 

59 

60_TARGET = RelayFormat.GEMINI 

61 

62_SAFETY_CATEGORIES = ( 

63 "HARM_CATEGORY_HARASSMENT", 

64 "HARM_CATEGORY_HATE_SPEECH", 

65 "HARM_CATEGORY_SEXUALLY_EXPLICIT", 

66 "HARM_CATEGORY_DANGEROUS_CONTENT", 

67) 

68 

69_MIME_KEY = "mimeType" 

70 

71_THOUGHT_SIGNATURE_BYPASS = "context_engineering_is_the_way_to_go" 

72 

73_SCHEMA_TYPE_MAP = { 

74 "string": "STRING", 

75 "object": "OBJECT", 

76 "array": "ARRAY", 

77 "integer": "INTEGER", 

78 "number": "NUMBER", 

79 "boolean": "BOOLEAN", 

80} 

81 

82 

83def _tool_call_from_part(part: GeminiPart) -> ToolCall: 

84 """Convert a Gemini ``functionCall`` part into a canonical ``ToolCall``. 

85 

86 Gemini function calls carry no stable id; the canonical id stays 

87 empty so target writers can generate a dialect-appropriate one. 

88 """ 

89 call = part.function_call or {} 

90 name = str(call.get("name", "")) 

91 args = call.get("args") 

92 return ToolCall( 

93 id="", 

94 type="custom", 

95 function=FunctionCall( 

96 name=name, 

97 arguments=args if isinstance(args, dict) else {}, 

98 ), 

99 ) 

100 

101 

102def _tool_call_to_part(tool_call: ToolCall) -> GeminiPart: 

103 """Serialize a canonical ``ToolCall`` as a Gemini ``functionCall`` part.""" 

104 arguments: Any = tool_call.function.arguments if tool_call.function else {} 

105 if isinstance(arguments, str): 

106 try: 

107 arguments = loads_str(arguments) 

108 except ValueError: 

109 arguments = {} 

110 elif not isinstance(arguments, dict): 

111 arguments = {} 

112 return GeminiPart( 

113 function_call={ 

114 "name": tool_call.function.name if tool_call.function else "", 

115 "args": arguments, 

116 } 

117 ) 

118 

119 

120class GeminiMapper: 

121 """Bidirectional Google Gemini ``generateContent`` converter. 

122 

123 Attributes: 

124 format: The wire format this mapper handles. 

125 """ 

126 

127 format = _TARGET 

128 

129 def request_to_ir( 

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

131 ) -> Result[RelayRequest, RelayError]: 

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

133 

134 Args: 

135 payload: A wire request DTO. 

136 context: Per-conversion context with loss sink. 

137 

138 Returns: 

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

140 """ 

141 if not isinstance(payload, GeminiRequest): 

142 return Err( 

143 unsupported_format( 

144 f"expected GeminiRequest, got {type(payload).__name__}" 

145 ) 

146 ) 

147 try: 

148 messages = [ 

149 chat_message 

150 for index, content in enumerate(payload.contents) 

151 for chat_message in self._content_to_ir(content, context, index) 

152 ] 

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

154 if payload.safety_settings is not None: 

155 metadata["safety_settings"] = [ 

156 dict(item) for item in payload.safety_settings 

157 ] 

158 if payload.tool_config is not None: 

159 metadata["tool_config"] = dict(payload.tool_config) 

160 generation_config = dict(payload.generation_config) 

161 if generation_config: 

162 metadata["generation_config"] = generation_config 

163 return Ok( 

164 RelayRequest( 

165 model=str(payload.passthrough.get("model", "")).strip(), 

166 messages=messages, 

167 system=self._system_to_ir(payload.system_instruction), 

168 tools=self._tools_to_ir(payload.tools), 

169 temperature=_config_number(generation_config, "temperature"), 

170 top_p=_config_number(generation_config, "topP"), 

171 top_k=_config_int(generation_config, "topK"), 

172 max_tokens=_config_int(generation_config, "maxOutputTokens"), 

173 stop_sequences=[ 

174 str(item) 

175 for item in generation_config.get("stopSequences", []) 

176 if isinstance(item, str) 

177 ], 

178 response_format=self._response_format_to_ir(generation_config), 

179 thinking=self._thinking_to_ir(generation_config), 

180 metadata=metadata, 

181 passthrough=dict(payload.passthrough), 

182 ) 

183 ) 

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

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

186 

187 def ir_to_request( 

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

189 ) -> Result[Any, RelayError]: 

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

191 

192 Args: 

193 request: Canonical request IR. 

194 context: Per-conversion context with loss sink. 

195 

196 Returns: 

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

198 """ 

199 try: 

200 system_parts: list[str] = [] 

201 contents: list[GeminiContent] = [] 

202 tool_names, tool_names_by_id = self._tool_name_resolver(request) 

203 for message in request.messages: 

204 if message.role == "system": 

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

206 continue 

207 content = self._content_from_ir( 

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

209 ) 

210 if content.is_err(): 

211 return content 

212 contents.append(content.unwrap()) 

213 if request.system: 

214 system_parts.append(request.system) 

215 return Ok( 

216 GeminiRequest( 

217 contents=contents, 

218 system_instruction=( 

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

220 if system_parts 

221 else None 

222 ), 

223 generation_config=self._generation_config_from_ir(request, context), 

224 safety_settings=self._safety_settings_from_ir(request, context), 

225 tools=self._tools_from_ir(request.tools), 

226 tool_config=self._tool_config_from_ir(request), 

227 passthrough=self._request_passthrough(request), 

228 ) 

229 ) 

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

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

232 

233 def response_to_ir( 

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

235 ) -> Result[RelayResponse, RelayError]: 

236 """Convert a ``GeminiResponse`` into canonical ``RelayResponse``. 

237 

238 Args: 

239 payload: A wire response DTO. 

240 context: Per-conversion context with loss sink. 

241 

242 Returns: 

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

244 """ 

245 if not isinstance(payload, GeminiResponse): 

246 return Err( 

247 unsupported_format( 

248 f"expected GeminiResponse, got {type(payload).__name__}" 

249 ) 

250 ) 

251 try: 

252 candidates = payload.candidates or [] 

253 if len(candidates) > 1: 

254 record_loss( 

255 context, 

256 field="candidates", 

257 target=_TARGET, 

258 reason="multiple_candidates_collapsed", 

259 ) 

260 candidate = candidates[0] if candidates else None 

261 passthrough: dict[str, Any] = dict(payload.passthrough) 

262 if payload.model_version is not None: 

263 passthrough["model_version"] = payload.model_version 

264 if payload.create_time is not None: 

265 passthrough["create_time"] = payload.create_time 

266 if payload.prompt_feedback is not None: 

267 passthrough["prompt_feedback"] = payload.prompt_feedback.to_dict() 

268 content = "" 

269 thinking: ThinkingResult | None = None 

270 tool_calls: list[ToolCall] = [] 

271 if candidate is not None and candidate.content is not None: 

272 text_parts: list[str] = [] 

273 think_parts: list[str] = [] 

274 think_signature: str | None = None 

275 for part in candidate.content.parts: 

276 if part.thought: 

277 think_parts.append(part.text or "") 

278 think_signature = part.thought_signature 

279 elif part.text is not None: 

280 text_parts.append(part.text) 

281 elif part.function_call is not None: 

282 tool_calls.append(_tool_call_from_part(part)) 

283 elif ( 

284 part.inline_data is not None 

285 or part.function_response is not None 

286 ): 

287 record_loss( 

288 context, 

289 field="content.part", 

290 target=_TARGET, 

291 reason="unrepresentable_part_dropped", 

292 ) 

293 content = "".join(text_parts) 

294 if think_parts: 

295 thinking = ThinkingResult( 

296 content="".join(think_parts), 

297 signature=think_signature, 

298 tokens=self._thought_tokens(payload), 

299 ) 

300 self._preserve_candidate_metadata(candidate, passthrough) 

301 return Ok( 

302 RelayResponse( 

303 model=payload.model_version or "", 

304 id=payload.response_id, 

305 content=content, 

306 thinking=thinking, 

307 tool_calls=tool_calls, 

308 finish_reason=normalize_finish_reason( 

309 candidate.finish_reason if candidate else None 

310 ), 

311 usage=self._usage_from_wire(payload.usage_metadata), 

312 passthrough=passthrough, 

313 ) 

314 ) 

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

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

317 

318 def ir_to_response( 

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

320 ) -> Result[Any, RelayError]: 

321 """Convert canonical ``RelayResponse`` into a ``GeminiResponse``. 

322 

323 Args: 

324 response: Canonical response IR. 

325 context: Per-conversion context with loss sink. 

326 

327 Returns: 

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

329 """ 

330 try: 

331 passthrough = dict(response.passthrough) 

332 model_version = passthrough.pop("model_version", None) 

333 prompt_feedback = passthrough.pop("prompt_feedback", None) 

334 create_time = passthrough.pop("create_time", None) 

335 safety_ratings = passthrough.pop("safety_ratings", None) 

336 grounding_metadata = passthrough.pop("grounding_metadata", None) 

337 citation_metadata = passthrough.pop("citation_metadata", None) 

338 token_count = passthrough.pop("token_count", None) 

339 avg_logprobs = passthrough.pop("avg_logprobs", None) 

340 parts: list[GeminiPart] = [] 

341 if response.content: 

342 parts.append(GeminiPart(text=response.content)) 

343 for tool_call in response.tool_calls: 

344 parts.append(_tool_call_to_part(tool_call)) 

345 candidate = GeminiCandidate( 

346 content=GeminiContent(role="model", parts=parts), 

347 finish_reason=self._finish_reason_from_ir( 

348 response.finish_reason, context 

349 ), 

350 index=0, 

351 safety_ratings=self._safety_ratings_from_passthrough(safety_ratings) 

352 or [], 

353 grounding_metadata=self._grounding_from_passthrough(grounding_metadata), 

354 citation_metadata=( 

355 citation_metadata if isinstance(citation_metadata, dict) else None 

356 ), 

357 token_count=token_count if isinstance(token_count, int) else None, 

358 avg_logprobs=( 

359 avg_logprobs if isinstance(avg_logprobs, (int, float)) else None 

360 ), 

361 passthrough=dict(passthrough), 

362 ) 

363 return Ok( 

364 GeminiResponse( 

365 candidates=[candidate], 

366 prompt_feedback=self._prompt_feedback_from_passthrough( 

367 prompt_feedback 

368 ), 

369 usage_metadata=self._usage_to_wire(response.usage), 

370 model_version=model_version 

371 if isinstance(model_version, str) 

372 else None, 

373 create_time=create_time if isinstance(create_time, str) else None, 

374 passthrough=passthrough, 

375 ) 

376 ) 

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

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

379 

380 def stream_to_delta( 

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

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

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

384 return Err( 

385 unsupported_feature("gemini stream conversion is not implemented yet") 

386 ) 

387 

388 def delta_to_stream( 

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

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

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

392 return Err( 

393 unsupported_feature("gemini stream conversion is not implemented yet") 

394 ) 

395 

396 # -- helpers ------------------------------------------------------------- 

397 

398 @staticmethod 

399 def _system_to_ir( 

400 system_instruction: dict[str, Any] | None, 

401 ) -> str | None: 

402 """Extract system text from a Gemini ``systemInstruction`` dict.""" 

403 if not isinstance(system_instruction, dict): 

404 return None 

405 parts = system_instruction.get("parts") 

406 if not isinstance(parts, list): 

407 return None 

408 texts: list[str] = [] 

409 for part in parts: 

410 if isinstance(part, dict) and part.get("text") is not None: 

411 texts.append(str(part["text"])) 

412 return "\n".join(texts) 

413 

414 @staticmethod 

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

416 """Convert Gemini wire tools into canonical ``ToolDefinition`` objects.""" 

417 definitions: list[ToolDefinition] = [] 

418 for tool in tools or []: 

419 if not isinstance(tool, dict): 

420 continue 

421 declarations = tool.get("functionDeclarations") 

422 if not isinstance(declarations, list): 

423 continue 

424 for declaration in declarations: 

425 if not isinstance(declaration, dict): 

426 continue 

427 parameters = declaration.get("parameters") 

428 definitions.append( 

429 ToolDefinition( 

430 name=str(declaration.get("name", "")), 

431 description=str(declaration.get("description", "")), 

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

433 ) 

434 ) 

435 return definitions 

436 

437 def _content_to_ir( 

438 self, content: GeminiContent, context: ConversionContext, index: int 

439 ) -> list[ChatMessage]: 

440 """Convert one Gemini content turn into canonical messages.""" 

441 if content.role == "model": 

442 return [self._assistant_to_ir(content, context)] 

443 if content.role == "user": 

444 return self._user_to_ir(content, context, index) 

445 if content.role == "function": 

446 return self._function_to_ir(content, context, index) 

447 record_loss( 

448 context, 

449 field=f"contents[{index}].role", 

450 target=_TARGET, 

451 reason="unknown_role_dropped", 

452 ) 

453 return [] 

454 

455 def _assistant_to_ir( 

456 self, content: GeminiContent, context: ConversionContext 

457 ) -> ChatMessage: 

458 """Convert a model content turn, separating thinking/tool parts.""" 

459 text_parts: list[str] = [] 

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

461 tool_calls: list[ToolCall] = [] 

462 for part in content.parts: 

463 if part.thought: 

464 thinking_blocks.append( 

465 { 

466 "thought": True, 

467 "text": part.text or "", 

468 "thoughtSignature": part.thought_signature or "", 

469 } 

470 ) 

471 elif part.text is not None: 

472 text_parts.append(part.text) 

473 elif part.function_call is not None: 

474 tool_calls.append(_tool_call_from_part(part)) 

475 else: 

476 record_loss( 

477 context, 

478 field="content.part", 

479 target=_TARGET, 

480 reason="unrepresentable_part_dropped", 

481 ) 

482 return ChatMessage( 

483 role="assistant", 

484 content="".join(text_parts), 

485 tool_calls=tool_calls or None, 

486 thinking_blocks=thinking_blocks or None, 

487 ) 

488 

489 def _user_to_ir( 

490 self, content: GeminiContent, context: ConversionContext, index: int 

491 ) -> list[ChatMessage]: 

492 """Convert a user content turn into canonical parts and tool results.""" 

493 parts: list[ContentPart] = [] 

494 tool_results: list[ChatMessage] = [] 

495 for part in content.parts: 

496 if part.text is not None: 

497 parts.append(TextPart(text=part.text)) 

498 elif part.inline_data is not None: 

499 inline = part.inline_data 

500 parts.append( 

501 ImageBase64Part( 

502 data=str(inline.get("data", "")), 

503 media_type=str(inline.get(_MIME_KEY, "")), 

504 detail="auto", 

505 ) 

506 ) 

507 elif part.function_response is not None: 

508 tool_results.append( 

509 self._function_response_to_ir(part.function_response) 

510 ) 

511 else: 

512 record_loss( 

513 context, 

514 field=f"contents[{index}].part", 

515 target=_TARGET, 

516 reason="unrepresentable_part_dropped", 

517 ) 

518 turns: list[ChatMessage] = [] 

519 if parts: 

520 turns.append( 

521 ChatMessage( 

522 role="user", 

523 content=( 

524 parts[0].text 

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

526 else list(parts) 

527 ), 

528 ) 

529 ) 

530 turns.extend(tool_results) 

531 if not turns: 

532 record_loss( 

533 context, 

534 field=f"contents[{index}]", 

535 target=_TARGET, 

536 reason="empty_message_dropped", 

537 ) 

538 return turns 

539 

540 def _function_to_ir( 

541 self, content: GeminiContent, context: ConversionContext, index: int 

542 ) -> list[ChatMessage]: 

543 """Convert a function content turn into canonical tool messages.""" 

544 messages: list[ChatMessage] = [] 

545 for part in content.parts: 

546 if part.function_response is not None: 

547 messages.append(self._function_response_to_ir(part.function_response)) 

548 else: 

549 record_loss( 

550 context, 

551 field=f"contents[{index}].part", 

552 target=_TARGET, 

553 reason="unrepresentable_part_dropped", 

554 ) 

555 return messages 

556 

557 @staticmethod 

558 def _function_response_to_ir(response: dict[str, Any]) -> ChatMessage: 

559 """Convert a ``functionResponse`` dict into a canonical tool message.""" 

560 payload = response.get("response") 

561 if isinstance(payload, str): 

562 text = payload 

563 else: 

564 text = dumps_str(payload) if payload is not None else "" 

565 return ChatMessage(role="tool", content=text, tool_call_id="") 

566 

567 @staticmethod 

568 def _response_format_to_ir( 

569 generation_config: dict[str, Any], 

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

571 """Derive a canonical response format from the generation config.""" 

572 mime = generation_config.get("responseMimeType") 

573 if not isinstance(mime, str): 

574 return None 

575 if mime == "application/json": 

576 return {"type": "json_object"} 

577 return None 

578 

579 def _thinking_to_ir( 

580 self, generation_config: dict[str, Any] 

581 ) -> ThinkingConfig | None: 

582 """Extract canonical thinking config from ``thinkingConfig``.""" 

583 config = generation_config.get("thinkingConfig") 

584 if not isinstance(config, dict): 

585 return None 

586 level = config.get("thinkingLevel") 

587 budget = config.get("thinkingBudget") 

588 if isinstance(level, str) and level: 

589 return ThinkingConfig(level=level) 

590 if isinstance(budget, int): 

591 return ThinkingConfig(budget_tokens=budget) 

592 return None 

593 

594 def _content_from_ir( 

595 self, 

596 message: ChatMessage, 

597 model: str, 

598 tool_names: list[str], 

599 tool_names_by_id: dict[str, str], 

600 context: ConversionContext, 

601 ) -> Result[GeminiContent, RelayError]: 

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

603 

604 Args: 

605 message: Canonical message to serialize. 

606 model: The selected model, for capability lookups. 

607 tool_names: Positional resolver for tool-message function 

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

609 stable link. 

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

611 context: Per-conversion context with loss sink. 

612 

613 Returns: 

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

615 """ 

616 if message.role == "tool": 

617 response: Any = message.content 

618 if isinstance(response, list): 

619 response = self._text_from_content(message.content) 

620 if isinstance(response, str): 

621 try: 

622 response = loads_str(response) 

623 except ValueError: 

624 pass 

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

626 if not name and tool_names: 

627 name = tool_names.pop(0) 

628 if isinstance(response, str): 

629 response = {"content": response} 

630 return Ok( 

631 GeminiContent( 

632 role="user", 

633 parts=[ 

634 GeminiPart( 

635 function_response={ 

636 "name": name, 

637 "response": response, 

638 } 

639 ) 

640 ], 

641 ) 

642 ) 

643 if message.role == "assistant": 

644 parts = self._assistant_parts_from_ir(message, model, context) 

645 if parts.is_err(): 

646 return Err(parts.unwrap_err()) 

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

648 if message.role == "user": 

649 parts = self._user_parts_from_ir(message.content, context) 

650 if parts.is_err(): 

651 return Err(parts.unwrap_err()) 

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

653 record_loss( 

654 context, 

655 field="messages", 

656 target=_TARGET, 

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

658 ) 

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

660 

661 @staticmethod 

662 def _tool_name_resolver( 

663 request: RelayRequest, 

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

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

666 

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

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

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

670 positional order against the assistant tool calls that preceded 

671 them. 

672 """ 

673 names: list[str] = [] 

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

675 for message in request.messages: 

676 if message.role == "assistant": 

677 for tool_call in message.tool_calls or []: 

678 if tool_call.function: 

679 names.append(tool_call.function.name) 

680 if tool_call.id: 

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

682 return names, names_by_id 

683 

684 def _assistant_parts_from_ir( 

685 self, message: ChatMessage, model: str, context: ConversionContext 

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

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

688 

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

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

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

692 ``FunctionCallThoughtSignatureEnabled`` behavior). Otherwise 

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

694 """ 

695 attach_signature = context.options.gemini.thought_signature_bypass 

696 parts: list[GeminiPart] = [] 

697 if not attach_signature: 

698 for block in message.thinking_blocks or []: 

699 if not isinstance(block, dict): 

700 continue 

701 signature = block.get("thoughtSignature") 

702 parts.append( 

703 GeminiPart( 

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

705 thought=True, 

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

707 ) 

708 ) 

709 content_parts = self._user_parts_from_ir(message.content, context) 

710 if content_parts.is_err(): 

711 return Err(content_parts.unwrap_err()) 

712 parts.extend(content_parts.unwrap()) 

713 for tool_call in message.tool_calls or []: 

714 parts.append(_tool_call_to_part(tool_call)) 

715 if attach_signature: 

716 parts = self._attach_thought_signature(parts) 

717 return Ok(parts) 

718 

719 @staticmethod 

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

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

722 

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

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

725 """ 

726 rebuilt: list[GeminiPart] = [] 

727 attached = False 

728 for part in parts: 

729 current = part 

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

731 current = replace(part, thought_signature=_THOUGHT_SIGNATURE_BYPASS) 

732 attached = True 

733 rebuilt.append(current) 

734 if not attached: 

735 for index, part in enumerate(rebuilt): 

736 if part.text: 

737 rebuilt[index] = replace( 

738 part, thought_signature=_THOUGHT_SIGNATURE_BYPASS 

739 ) 

740 break 

741 return rebuilt 

742 

743 def _user_parts_from_ir( 

744 self, content: str | list[ContentPart], context: ConversionContext 

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

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

747 if isinstance(content, str): 

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

749 parts: list[GeminiPart] = [] 

750 for part in content: 

751 if isinstance(part, TextPart): 

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

753 elif isinstance(part, ImageBase64Part): 

754 parts.append( 

755 GeminiPart( 

756 inline_data={ 

757 _MIME_KEY: part.media_type, 

758 "data": part.data, 

759 } 

760 ) 

761 ) 

762 elif isinstance(part, ImageUrlPart): 

763 resolved = self._resolve_image(part, context) 

764 if resolved.is_err(): 

765 return Err(resolved.unwrap_err()) 

766 media_type, data = resolved.unwrap() 

767 parts.append( 

768 GeminiPart(inline_data={_MIME_KEY: media_type, "data": data}) 

769 ) 

770 else: 

771 record_loss( 

772 context, 

773 field="message.content", 

774 target=_TARGET, 

775 reason="unknown_content_part", 

776 ) 

777 return Ok(parts) 

778 

779 @staticmethod 

780 def _resolve_image( 

781 part: ImageUrlPart, context: ConversionContext 

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

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

784 

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

786 """ 

787 resolved = resolve_media( 

788 part.url, 

789 context, 

790 field="message.content", 

791 target=_TARGET, 

792 lossy=False, 

793 ) 

794 if resolved.is_err(): 

795 return Err(resolved.unwrap_err()) 

796 image = resolved.unwrap() 

797 assert image is not None # lossy=False never drops media 

798 return Ok(image) 

799 

800 @staticmethod 

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

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

803 if isinstance(content, str): 

804 return content 

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

806 

807 def _generation_config_from_ir( 

808 self, request: RelayRequest, context: ConversionContext 

809 ) -> dict[str, Any]: 

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

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

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

813 for key in ( 

814 "temperature", 

815 "topP", 

816 "topK", 

817 "maxOutputTokens", 

818 "stopSequences", 

819 "responseMimeType", 

820 "responseSchema", 

821 "thinkingConfig", 

822 ): 

823 config.pop(key, None) 

824 if request.temperature is not None: 

825 config["temperature"] = request.temperature 

826 if request.top_p is not None: 

827 config["topP"] = request.top_p 

828 if request.top_k is not None: 

829 config["topK"] = request.top_k 

830 if request.max_tokens is not None: 

831 config["maxOutputTokens"] = request.max_tokens 

832 if request.stop_sequences: 

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

834 if request.response_format is not None: 

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

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

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

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

839 thinking_config = self._thinking_config_from_ir(request, context) 

840 if thinking_config is not None: 

841 config["thinkingConfig"] = thinking_config 

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

843 request.model 

844 ): 

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

846 return config 

847 

848 def _thinking_config_from_ir( 

849 self, request: RelayRequest, context: ConversionContext 

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

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

852 if request.thinking is not None: 

853 record_loss( 

854 context, 

855 field="thinking", 

856 target=_TARGET, 

857 reason="thinking_not_supported", 

858 ) 

859 if ( 

860 context.options.gemini.thinking_adapter_enabled 

861 and context.options.gemini.thinking_budget 

862 ): 

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

864 return None 

865 

866 @staticmethod 

867 def _safety_settings_from_ir( 

868 request: RelayRequest, context: ConversionContext 

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

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

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

872 if isinstance(raw, list): 

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

874 return preserved or None 

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

876 for category in _SAFETY_CATEGORIES: 

877 threshold = context.safety_setting(category) 

878 if threshold and isinstance(threshold, str): 

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

880 return collected or None 

881 

882 @staticmethod 

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

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

885 if not tools: 

886 return None 

887 return [ 

888 { 

889 "functionDeclarations": [ 

890 { 

891 "name": tool.name, 

892 "description": tool.description, 

893 "parameters": GeminiMapper._upper_schema_types(tool.parameters), 

894 } 

895 for tool in tools 

896 ] 

897 } 

898 ] 

899 

900 @staticmethod 

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

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

903 return dict(request.passthrough) 

904 

905 @staticmethod 

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

907 """Uppercase Gemini schema type markers recursively. 

908 

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

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

911 """ 

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

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

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

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

916 elif isinstance(value, dict): 

917 out[key] = GeminiMapper._upper_schema_types(value) 

918 elif isinstance(value, list): 

919 out[key] = [ 

920 GeminiMapper._upper_schema_types(item) 

921 if isinstance(item, dict) 

922 else item 

923 for item in value 

924 ] 

925 else: 

926 out[key] = value 

927 return out 

928 

929 @staticmethod 

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

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

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

933 if isinstance(raw, dict): 

934 return dict(raw) 

935 choice = request.tool_choice 

936 if isinstance(choice, dict): 

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

938 if isinstance(name, dict): 

939 name = name.get("name") 

940 if isinstance(name, str) and name: 

941 return { 

942 "functionCallingConfig": { 

943 "mode": "ANY", 

944 "allowedFunctionNames": [name], 

945 } 

946 } 

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

948 if isinstance(choice, str): 

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

950 choice, "AUTO" 

951 ) 

952 return {"functionCallingConfig": {"mode": mode}} 

953 return None 

954 

955 def _usage_from_wire(self, usage: GeminiUsageMetadata | None) -> RelayUsage | None: 

956 """Map a wire ``GeminiUsageMetadata`` into canonical ``RelayUsage``. 

957 

958 Mirrors relaykit's ``UsageFromGeminiMetadata``: completion counts 

959 thinking tokens, the prompt adds tool-use tokens, and the explicit 

960 total is preserved because Gemini counts thoughts within both. 

961 """ 

962 if usage is None: 

963 return None 

964 return RelayUsage( 

965 prompt_tokens=( 

966 usage.prompt_token_count + usage.tool_use_prompt_token_count 

967 ), 

968 completion_tokens=( 

969 usage.candidates_token_count + (usage.thoughts_token_count or 0) 

970 ), 

971 cache_read_tokens=usage.cached_content_token_count or 0, 

972 reasoning_tokens=usage.thoughts_token_count or 0, 

973 total_tokens_override=usage.total_token_count or None, 

974 ) 

975 

976 @staticmethod 

977 def _usage_to_wire(usage: RelayUsage | None) -> GeminiUsageMetadata | None: 

978 """Serialize canonical ``RelayUsage`` into a ``GeminiUsageMetadata``. 

979 

980 Gemini reports thinking tokens as a subset of the candidate 

981 tokens and does not surface cache or reasoning fields in the 

982 generated payload, so those counters are emitted as zeros. 

983 """ 

984 if usage is None: 

985 return None 

986 return GeminiUsageMetadata( 

987 prompt_token_count=usage.prompt_tokens, 

988 candidates_token_count=usage.completion_tokens, 

989 total_token_count=usage.total_tokens, 

990 cached_content_token_count=0, 

991 thoughts_token_count=0, 

992 tool_use_prompt_token_count=0, 

993 ) 

994 

995 @staticmethod 

996 def _thought_tokens(payload: GeminiResponse) -> int | None: 

997 """Read thinking tokens from the usage metadata.""" 

998 if ( 

999 payload.usage_metadata is None 

1000 or not payload.usage_metadata.thoughts_token_count 

1001 ): 

1002 return None 

1003 return payload.usage_metadata.thoughts_token_count 

1004 

1005 @staticmethod 

1006 def _preserve_candidate_metadata( 

1007 candidate: GeminiCandidate, passthrough: dict[str, Any] 

1008 ) -> None: 

1009 """Preserve candidate-level provider metadata as passthrough.""" 

1010 if candidate.safety_ratings: 

1011 passthrough["safety_ratings"] = [ 

1012 rating.to_dict() for rating in candidate.safety_ratings 

1013 ] 

1014 if candidate.grounding_metadata is not None: 

1015 passthrough["grounding_metadata"] = candidate.grounding_metadata.to_dict() 

1016 if candidate.citation_metadata is not None: 

1017 passthrough["citation_metadata"] = candidate.citation_metadata 

1018 if candidate.token_count is not None: 

1019 passthrough["token_count"] = candidate.token_count 

1020 if candidate.avg_logprobs is not None: 

1021 passthrough["avg_logprobs"] = candidate.avg_logprobs 

1022 passthrough.update(candidate.passthrough) 

1023 

1024 def _finish_reason_from_ir( 

1025 self, finish_reason: str | None, context: ConversionContext 

1026 ) -> str | None: 

1027 """Map a canonical finish reason back to a Gemini value.""" 

1028 if finish_reason is None: 

1029 return None 

1030 if finish_reason == "function_call": 

1031 record_loss( 

1032 context, 

1033 field="finish_reason", 

1034 target=_TARGET, 

1035 reason="function_call_adapted", 

1036 ) 

1037 elif finish_reason not in FINISH_REASON_TO_WIRE: 

1038 record_loss( 

1039 context, 

1040 field="finish_reason", 

1041 target=_TARGET, 

1042 reason="finish_reason_adapted", 

1043 ) 

1044 return finish_reason_to_wire(finish_reason, _TARGET) 

1045 

1046 @staticmethod 

1047 def _safety_ratings_from_passthrough( 

1048 raw: Any, 

1049 ) -> list[GeminiSafetyRating] | None: 

1050 """Rebuild safety ratings from passthrough dicts.""" 

1051 if not isinstance(raw, list): 

1052 return None 

1053 ratings = [ 

1054 GeminiSafetyRating.from_dict(item) for item in raw if isinstance(item, dict) 

1055 ] 

1056 return ratings or None 

1057 

1058 @classmethod 

1059 def _grounding_from_passthrough(cls, raw: Any) -> GeminiGroundingMetadata | None: 

1060 """Rebuild grounding metadata from a passthrough dict.""" 

1061 if not isinstance(raw, dict): 

1062 return None 

1063 return GeminiGroundingMetadata.from_dict(raw) 

1064 

1065 @staticmethod 

1066 def _prompt_feedback_from_passthrough( 

1067 raw: Any, 

1068 ) -> GeminiPromptFeedback | None: 

1069 """Rebuild prompt feedback from a passthrough dict.""" 

1070 if not isinstance(raw, dict): 

1071 return None 

1072 return GeminiPromptFeedback.from_dict(raw) 

1073 

1074 

1075def _config_number(generation_config: dict[str, Any], key: str) -> int | float | None: 

1076 """Read a numeric generation config value when well-typed.""" 

1077 value = generation_config.get(key) 

1078 if isinstance(value, (int, float)) and not isinstance(value, bool): 

1079 return value 

1080 return None 

1081 

1082 

1083def _config_int(generation_config: dict[str, Any], key: str) -> int | None: 

1084 """Read an integer generation config value when well-typed.""" 

1085 value = _config_number(generation_config, key) 

1086 if value is None or isinstance(value, float): 

1087 return None 

1088 return value