Skip to main content

gproxy_protocol/transform/generate/gemini_chat/stream/
gemini_to_chat.rs

1use super::{
2    common::{Budget, StreamEnd, chat_finish, id},
3    *,
4};
5use crate::transform::generate::stream::{
6    chat,
7    gemini::{GeminiStreamCollector, GeminiStreamLimits},
8};
9
10/// Factual invocation timestamp; `model` is an actual model identity when the
11/// source does not supply one. Without either model fact, chunks remain bounded
12/// and pending until the source supplies its model version.
13#[derive(Debug, Clone)]
14pub struct GeminiToChatContext {
15    pub created: i64,
16    pub model: Option<String>,
17}
18
19pub struct GeminiToChatStream {
20    source: Option<GeminiStreamCollector>,
21    policy: TargetIdPolicy,
22    flow: IdentityFlow,
23    budget: Budget,
24    calls: super::super::content::Calls,
25    signatures: super::super::content::Signatures,
26    tool_indexes: BTreeMap<i64, i64>,
27    context: GeminiToChatContext,
28    response_id: Option<String>,
29    model: Option<String>,
30    pending: Vec<g::GenerateContentResponseBody>,
31    emitted: bool,
32    failed: bool,
33    report: Report,
34}
35
36impl GeminiToChatStream {
37    pub fn new(
38        context: GeminiToChatContext,
39        flow: IdentityFlow,
40        limits: StreamLimits,
41    ) -> Result<Self, TransformError> {
42        Self::new_with_policy(
43            context,
44            flow,
45            limits,
46            TargetIdPolicy::new(crate::Dialect::OpenAiChat),
47        )
48    }
49    pub fn new_with_policy(
50        context: GeminiToChatContext,
51        flow: IdentityFlow,
52        limits: StreamLimits,
53        policy: TargetIdPolicy,
54    ) -> Result<Self, TransformError> {
55        if policy.dialect != crate::Dialect::OpenAiChat {
56            return Err(TransformError::shape(
57                "stream.policy",
58                "target policy dialect mismatch",
59            ));
60        }
61
62        if context.created < 0 || context.model.as_ref().is_some_and(|v| v.is_empty()) {
63            return Err(invalid("invalid factual creation/model context"));
64        }
65        if let Some(model) = &context.model {
66            super::common::bounded(model, limits.max_bytes)?;
67        }
68        Ok(Self {
69            source: Some(GeminiStreamCollector::new(GeminiStreamLimits {
70                max_bytes: limits.max_bytes,
71            })),
72            policy,
73            flow,
74            budget: Budget::new(limits),
75            calls: Default::default(),
76            signatures: Default::default(),
77            tool_indexes: BTreeMap::new(),
78            model: context.model.clone(),
79            context,
80            response_id: None,
81            pending: Vec::new(),
82            emitted: false,
83            failed: false,
84            report: Default::default(),
85        })
86    }
87    pub fn identities(&self) -> &IdentityFlow {
88        &self.flow
89    }
90    pub fn push(
91        &mut self,
92        chunk: g::GenerateContentResponseBody,
93    ) -> Result<Converted<Vec<s::ChatCompletionChunk>>, TransformError> {
94        if self.failed {
95            return Err(invalid("stream already failed"));
96        }
97        let result = self.push_inner(chunk.into_declared());
98        if result.is_err() {
99            self.failed = true;
100        }
101        result
102    }
103    fn push_inner(
104        &mut self,
105        chunk: g::GenerateContentResponseBody,
106    ) -> Result<Converted<Vec<s::ChatCompletionChunk>>, TransformError> {
107        self.budget.input(&chunk)?;
108        self.source
109            .as_mut()
110            .ok_or_else(|| invalid("source consumed"))?
111            .push(chunk.clone())?;
112        if let Some(model) = &chunk.model_version {
113            if self.emitted && self.model.as_ref().is_some_and(|v| v != model) {
114                return Err(invalid(
115                    "source model contradicts already emitted model fact",
116                ));
117            }
118            self.model = Some(model.clone());
119        }
120        if let Some(value) = &chunk.response_id {
121            self.response_id = Some(value.clone());
122        }
123        if self.model.is_none() {
124            self.pending.push(chunk);
125            return Ok(Converted {
126                value: Vec::new(),
127                report: Report::default(),
128            });
129        }
130        let mut output = Vec::new();
131        for pending in std::mem::take(&mut self.pending) {
132            self.content(pending, &mut output)?;
133        }
134        self.content(chunk, &mut output)?;
135        self.emitted |= !output.is_empty();
136        Ok(Converted {
137            value: output,
138            report: std::mem::take(&mut self.report),
139        })
140    }
141    fn content(
142        &mut self,
143        chunk: g::GenerateContentResponseBody,
144        out: &mut Vec<s::ChatCompletionChunk>,
145    ) -> Result<(), TransformError> {
146        let policy = self.policy.clone();
147        // Resolve again when a late source ID arrives. IdentityFlow attaches it to
148        // the same logical response without changing an already emitted client ID.
149        let response = id(
150            &mut self.flow,
151            &policy,
152            IdentityRole::Response,
153            crate::Dialect::Gemini,
154            self.response_id.clone(),
155            0,
156        )?;
157        for candidate in chunk.candidates.into_iter().flatten() {
158            let index = candidate.index.unwrap_or(0);
159            if let Some(reason) = candidate.finish_reason
160                && reason != g::FinishReason::Unspecified
161            {
162                chat_finish(
163                    reason,
164                    self.tool_indexes.get(&index).is_some_and(|n| *n > 0),
165                )?;
166            }
167            let Some(mut content) = candidate.content else {
168                continue;
169            };
170            if content.role.is_none() {
171                content.role = Some("model".into());
172            }
173            if content.role.as_deref() != Some("model") {
174                return Err(invalid("generated candidate must have model role"));
175            }
176
177            for part in content.parts.iter().flatten() {
178                if part
179                    .function_call
180                    .as_ref()
181                    .is_some_and(|call| call.name.is_empty())
182                {
183                    return Err(TransformError::missing_metadata("function_call.name"));
184                }
185            }
186            let messages = super::super::content::gemini_content_to_chat(
187                content,
188                &mut self.report,
189                &mut self.flow,
190                &policy,
191                &mut self.calls,
192                Some(&mut self.signatures),
193            )?;
194            for message in messages {
195                let c::ChatMessage::Assistant(message) = message else {
196                    return Err(invalid(
197                        "model candidate produced nonassistant Chat message",
198                    ));
199                };
200                let mut delta = s::Delta::builder().role(s::DeltaRole::Assistant).build();
201                let mut has_content = false;
202                if let Some(details) = message.reasoning_details.clone().flatten() {
203                    delta.reasoning_details = Some(Some(details));
204                    has_content = true;
205                }
206                if let Some(text) = c::visible_reasoning(
207                    &message.reasoning_content,
208                    &message.reasoning,
209                    &message.reasoning_details,
210                ) {
211                    delta.reasoning_content = Some(Some(text));
212                    has_content = true;
213                }
214                if let Some(content) = message.content.flatten() {
215                    let mut text = String::new();
216                    match content {
217                        c::AssistantContent::Text(v) => text = v,
218                        c::AssistantContent::Parts(parts) => {
219                            for part in parts {
220                                match part {
221                                    c::AssistantContentPart::Text(v) => text.push_str(&v.text),
222                                    c::AssistantContentPart::Refusal(_) => {
223                                        return Err(invalid(
224                                            "unexpected refusal in model text mapping",
225                                        ));
226                                    }
227                                }
228                            }
229                        }
230                    }
231                    delta.content = Some(Some(text));
232                    has_content = true;
233                }
234                let mut tools = Vec::new();
235                for tool in message.tool_calls.into_iter().flatten() {
236                    let c::MessageToolCall::Function(tool) = tool else {
237                        return Err(invalid("Gemini function produced nonfunction call"));
238                    };
239                    let next = self.tool_indexes.entry(index).or_default();
240                    let mut call = s::DeltaToolCall::builder(*next).build();
241                    *next = next.checked_add(1).ok_or_else(limit)?;
242                    call.id = Some(Some(tool.id));
243                    call.type_ = Some(Some(s::DeltaToolCallType::Function));
244                    call.function = Some(Some(
245                        s::DeltaFunctionCall::builder()
246                            .name(tool.function.name)
247                            .arguments(tool.function.arguments)
248                            .build(),
249                    ));
250                    tools.push(call);
251                }
252                if !tools.is_empty() {
253                    delta.tool_calls = Some(Some(tools));
254                    has_content = true;
255                }
256                if has_content {
257                    let chunk = self.chunk(
258                        response.clone(),
259                        vec![s::StreamChoice::builder(index, delta).build()],
260                    );
261                    self.emit(chunk, out)?;
262                }
263            }
264        }
265        Ok(())
266    }
267    fn emit(
268        &mut self,
269        chunk: s::ChatCompletionChunk,
270        out: &mut Vec<s::ChatCompletionChunk>,
271    ) -> Result<(), TransformError> {
272        // Meter before retaining each chunk, including a late-model flush of
273        // many pending source events. Never multiply metadata across an
274        // unchecked batch.
275        self.budget.output(std::slice::from_ref(&chunk))?;
276        out.push(chunk);
277        Ok(())
278    }
279    fn chunk(&self, response: String, choices: Vec<s::StreamChoice>) -> s::ChatCompletionChunk {
280        s::ChatCompletionChunk::builder(
281            response,
282            choices,
283            self.context.created,
284            self.model.clone().expect("model checked before emission"),
285            s::ChunkObject::ChatCompletionChunk,
286        )
287        .build()
288    }
289    pub fn finish(mut self) -> Result<StreamEnd<s::ChatCompletionChunk>, TransformError> {
290        if self.failed {
291            return Err(invalid("stream failed before finish"));
292        }
293        let source = self
294            .source
295            .take()
296            .ok_or_else(|| invalid("source consumed"))?
297            .finish()?
298            .value;
299        if self.model.is_none() {
300            return Err(TransformError::missing_metadata(
301                "response.model_version or factual context.model",
302            ));
303        }
304        let mut chunks = Vec::new();
305        for pending in std::mem::take(&mut self.pending) {
306            self.content(pending, &mut chunks)?;
307        }
308        let policy = self.policy.clone();
309        let response = id(
310            &mut self.flow,
311            &policy,
312            IdentityRole::Response,
313            crate::Dialect::Gemini,
314            source.response_id,
315            0,
316        )?;
317        let candidates = source
318            .candidates
319            .filter(|v| !v.is_empty())
320            .ok_or_else(|| invalid("prompt blocked without generated candidates"))?;
321        let mut choices = Vec::new();
322        for (expected, candidate) in candidates.into_iter().enumerate() {
323            let index = candidate.index.unwrap_or(0);
324            if index != expected as i64 {
325                return Err(invalid(
326                    "final candidate indexes cannot form a complete native Chat choice set",
327                ));
328            }
329            let finish = chat_finish(
330                candidate
331                    .finish_reason
332                    .ok_or_else(|| TransformError::missing_metadata("candidate.finish_reason"))?,
333                self.tool_indexes.get(&index).is_some_and(|n| *n > 0),
334            )?;
335            let logs = candidate
336                .logprobs_result
337                .map(|v| super::super::logs::to_chat(v, &mut self.report))
338                .map(crate::transform::optional)
339                .transpose()?
340                .flatten()
341                .map(chat::logs::synthesize)
342                .map(crate::transform::optional)
343                .transpose()?
344                .flatten();
345            let mut choice = s::StreamChoice::builder(
346                index,
347                s::Delta::builder().role(s::DeltaRole::Assistant).build(),
348            )
349            .finish_reason(finish)
350            .build();
351            choice.logprobs = logs.map(Some);
352            choices.push(choice);
353            for (present, field) in [
354                (candidate.safety_ratings.is_some(), "safety_ratings"),
355                (candidate.citation_metadata.is_some(), "citation_metadata"),
356                (
357                    candidate.grounding_attributions.is_some(),
358                    "grounding_attributions",
359                ),
360                (candidate.grounding_metadata.is_some(), "grounding_metadata"),
361                (candidate.avg_logprobs.is_some(), "avg_logprobs"),
362                (
363                    candidate.url_context_metadata.is_some(),
364                    "url_context_metadata",
365                ),
366                (candidate.token_count.is_some(), "token_count"),
367                (candidate.finish_message.is_some(), "finish_message"),
368            ] {
369                if present {
370                    self.report.omitted(
371                        format!("candidate.{field}"),
372                        "Chat stream has no corresponding declared metadata field",
373                    );
374                }
375            }
376        }
377        let terminal = self.chunk(response.clone(), choices);
378        self.emit(terminal, &mut chunks)?;
379        if let Some(usage) = source.usage_metadata {
380            let usage = super::super::usage::to_chat(&usage, &mut self.report)?;
381            let mut tail = self.chunk(response, Vec::new());
382            tail.usage = Some(Some(chat::usage::synthesize(usage)?));
383            self.emit(tail, &mut chunks)?;
384        }
385        if source.prompt_feedback.is_some() {
386            self.report.omitted(
387                "prompt_feedback",
388                "Chat has no equivalent Gemini prompt feedback",
389            );
390        }
391        if source.model_status.is_some() {
392            self.report
393                .omitted("model_status", "Chat has no Gemini model-status field");
394        }
395        Ok(StreamEnd {
396            chunks,
397            identities: self.flow,
398            report: self.report,
399        })
400    }
401}