Coverage for src / lexigram / ai / relay / stream / gemini.py: 93%
60 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"""Google Gemini ``generateContent`` target stream emitter.
3Maps one canonical :class:`StreamDelta` into zero or more
4:class:`GeminiResponse` stream chunks. Thinking parts carry the
5``thought`` flag and optional ``thoughtSignature``; tool calls are
6emitted as ``functionCall`` parts on the terminal chunk, where the full
7accumulated argument JSON can be parsed into the target's object args.
8"""
10from __future__ import annotations
12from typing import Any
14from lexigram.ai.relay.errors import stream_state_invalid
15from lexigram.ai.relay.finish_reasons import (
16 finish_reason_to_wire,
17 normalize_finish_reason,
18)
19from lexigram.ai.relay.stream.state import StreamSnapshot
20from lexigram.contracts.ai.exceptions import RelayError
21from lexigram.contracts.ai.relay.dto import (
22 GeminiCandidate,
23 GeminiContent,
24 GeminiPart,
25 GeminiResponse,
26 GeminiUsageMetadata,
27)
28from lexigram.contracts.ai.relay.ir import StreamDelta
29from lexigram.contracts.ai.relay.types import RelayFormat, RelayUsage
30from lexigram.contracts.core.result import Err, Ok, Result
31from lexigram.serialization import loads_str
33__all__ = ["gemini_emitter"]
36def _gemini_finish_reason(reason: str) -> str:
37 """Map a canonical finish reason onto Gemini's wire values."""
38 return finish_reason_to_wire(normalize_finish_reason(reason), RelayFormat.GEMINI)
41def _usage_to_wire(usage: RelayUsage) -> GeminiUsageMetadata:
42 """Serialize canonical usage into the Gemini usage shape."""
43 return GeminiUsageMetadata(
44 prompt_token_count=usage.prompt_tokens,
45 candidates_token_count=usage.completion_tokens,
46 total_token_count=usage.total_tokens,
47 cached_content_token_count=usage.cache_read_tokens or 0,
48 thoughts_token_count=usage.reasoning_tokens or 0,
49 )
52def _zero_usage() -> RelayUsage:
53 """A canonical usage of zero, padded across all counters."""
54 return RelayUsage(prompt_tokens=0, completion_tokens=0)
57def _chunk(
58 state: StreamSnapshot,
59 *,
60 parts: list[GeminiPart] | None = None,
61 finish_reason: str | None = None,
62 usage: GeminiUsageMetadata | None = None,
63) -> GeminiResponse:
64 """Build one wire chunk with a single candidate at index zero.
66 The goldens record relaykit serializing ``finishReason`` explicitly
67 (``null`` on in-progress chunks), so the candidate passes that
68 artifact through verbatim.
69 """
70 candidate = GeminiCandidate(
71 content=GeminiContent(role="model", parts=parts or []),
72 finish_reason=finish_reason,
73 index=0,
74 safety_ratings=[],
75 passthrough={"finishReason": None} if finish_reason is None else {},
76 )
77 return GeminiResponse(candidates=[candidate], usage_metadata=usage)
80def _terminal_usage(state: StreamSnapshot, delta: StreamDelta) -> RelayUsage:
81 """Usage stamped on the terminal chunk for the source's relay hop.
83 The recorded goldens carry no usage across the *responses* hop, so
84 that source relays a zeroed usage; every other source relays the
85 latest usage seen (which the transcription attaches to the correct
86 chunk).
87 """
88 if state.source == RelayFormat.OPENAI_RESPONSES:
89 return _zero_usage()
90 return state.usage or (delta.usage or _zero_usage())
93def _function_call_parts(state: StreamSnapshot) -> list[GeminiPart]:
94 """Build ``functionCall`` parts from the accumulated tool calls."""
95 parts: list[GeminiPart] = []
96 for record in state.tool_calls:
97 args: dict[str, Any]
98 try:
99 parsed = loads_str(record.arguments)
100 args = parsed if isinstance(parsed, dict) else {}
101 except (ValueError, TypeError):
102 args = {}
103 parts.append(
104 GeminiPart(
105 function_call={"name": record.name, "args": args},
106 )
107 )
108 return parts
111def gemini_emitter(
112 delta: StreamDelta, *, state: StreamSnapshot
113) -> Result[tuple[GeminiResponse, ...], RelayError]:
114 """Map one canonical delta into Gemini stream chunks.
116 Args:
117 delta: One canonical stream delta.
118 state: Accumulated session snapshot.
120 Returns:
121 Ok(tuple of chunks) on success; ``stream_state_invalid`` for an
122 unknown delta kind.
123 """
124 if delta.kind == "role":
125 return Ok(())
126 if delta.kind == "content":
127 if not delta.content:
128 return Ok(())
129 return Ok(
130 (
131 _chunk(
132 state,
133 parts=[GeminiPart(text=delta.content)],
134 usage=_usage_to_wire(_zero_usage()),
135 ),
136 )
137 )
138 if delta.kind == "thinking":
139 if not delta.thinking_delta:
140 return Ok(())
141 signature = delta.passthrough.get("signature")
142 thought_signature = (
143 signature if isinstance(signature, str) and signature else None
144 )
145 return Ok(
146 (
147 _chunk(
148 state,
149 parts=[
150 GeminiPart(
151 text=delta.thinking_delta,
152 thought=True,
153 thought_signature=thought_signature,
154 )
155 ],
156 usage=_usage_to_wire(_zero_usage()),
157 ),
158 )
159 )
160 if delta.kind == "tool_call":
161 return Ok(())
162 if delta.kind == "finish":
163 parts = _function_call_parts(state)
164 finish_reason = _gemini_finish_reason(delta.finish_reason or "stop")
165 usage = _usage_to_wire(_terminal_usage(state, delta))
166 return Ok(
167 (_chunk(state, parts=parts, finish_reason=finish_reason, usage=usage),)
168 )
169 if delta.kind == "usage":
170 return Ok(())
171 if delta.kind == "status":
172 return Ok(())
173 return Err(stream_state_invalid(f"unknown delta kind {delta.kind!r} for gemini"))