Skip to main content

gproxy_protocol/transform/generate/gemini_chat/
response.rs

1use crate::{
2    transform::{
3        Converted, Report, TransformError,
4        identity::{IdentityFlow, IdentityRole, SourceIdentity, TargetIdPolicy},
5    },
6    wire::{DeclaredFields, gemini as g, openai::chat as c},
7};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub struct GeminiChatResponseSupplement {
11    pub created_unix_seconds: Option<i64>,
12}
13
14pub fn gemini_to_openai_response(
15    input: g::GenerateContentResponseBody,
16    target_model: impl Into<String>,
17    supplement: &GeminiChatResponseSupplement,
18    flow: &mut IdentityFlow,
19    policy: &TargetIdPolicy,
20) -> Result<Converted<c::GenerateContentResponseBody>, TransformError> {
21    if policy.dialect != crate::Dialect::OpenAiChat {
22        return Err(TransformError::shape(
23            "identity.policy",
24            "expected Chat target policy",
25        ));
26    }
27    let input = input.into_declared();
28    let created = supplement
29        .created_unix_seconds
30        .ok_or_else(|| TransformError::missing_metadata("response.created"))?;
31    if created < 0 {
32        return Err(TransformError::invalid_result(
33            "created",
34            "negative timestamp",
35        ));
36    }
37    let model = input.model_version.unwrap_or_else(|| target_model.into());
38    if model.is_empty() {
39        return Err(TransformError::missing_metadata("response.model"));
40    }
41    let mut ids = flow.clone();
42    let mut report = Report::default();
43    let usage = input
44        .usage_metadata
45        .as_ref()
46        .map(|usage| super::usage::to_chat(usage, &mut report))
47        .map(crate::transform::optional)
48        .transpose()?
49        .flatten();
50    let candidates = input
51        .candidates
52        .filter(|candidates| !candidates.is_empty())
53        .ok_or_else(|| {
54            TransformError::invalid_result(
55                "candidates",
56                "no generated candidates; prompt block requires error handling",
57            )
58        })?;
59    let mut bindings = super::content::Calls::default();
60    let mut choices = Vec::new();
61    let mut seen = std::collections::BTreeSet::new();
62    for (ordinal, candidate) in candidates.into_iter().enumerate() {
63        let index = candidate.index.unwrap_or(ordinal as i64);
64        if index < 0 || !seen.insert(index) {
65            return Err(TransformError::invalid_result(
66                "candidates.index",
67                "negative or duplicate candidate index",
68            ));
69        }
70        let mut content = candidate
71            .content
72            .unwrap_or_else(|| g::Content::builder().role("model").build());
73        if content.role.is_none() {
74            content.role = Some("model".into());
75        }
76        let mut signatures = super::content::Signatures::default();
77        let mapped = super::content::gemini_content_to_chat(
78            content,
79            &mut report,
80            &mut ids,
81            policy,
82            &mut bindings,
83            Some(&mut signatures),
84        )?;
85        let mut text = String::new();
86        let mut has_text = false;
87        let mut reasoning = String::new();
88        let mut tool_calls = Vec::new();
89        let mut details = Vec::new();
90        for message in mapped {
91            match message {
92                c::ChatMessage::Assistant(message) => {
93                    if let Some(text) = c::visible_reasoning(
94                        &message.reasoning_content,
95                        &message.reasoning,
96                        &message.reasoning_details,
97                    ) {
98                        reasoning.push_str(&text);
99                    }
100                    if let Some(Some(content)) = message.content {
101                        match content {
102                            c::AssistantContent::Text(value) => {
103                                text.push_str(&value);
104                                has_text = true;
105                            }
106                            c::AssistantContent::Parts(parts) => {
107                                for part in parts {
108                                    match part {
109                                        c::AssistantContentPart::Text(part) => {
110                                            text.push_str(&part.text);
111                                            has_text = true;
112                                        }
113                                        c::AssistantContentPart::Refusal(_) => {
114                                            return Err(TransformError::invalid_result(
115                                                "content",
116                                                "unexpected internal refusal",
117                                            ));
118                                        }
119                                    }
120                                }
121                            }
122                        }
123                    }
124                    tool_calls.extend(message.tool_calls.unwrap_or_default());
125                    details.extend(message.reasoning_details.flatten().into_iter().flatten());
126                }
127                c::ChatMessage::User(_)
128                | c::ChatMessage::System(_)
129                | c::ChatMessage::Developer(_)
130                | c::ChatMessage::Tool(_)
131                | c::ChatMessage::Function(_) => {
132                    return Err(TransformError::invalid_result(
133                        "candidate.content.role",
134                        "candidate must have model role",
135                    ));
136                }
137            }
138        }
139        let finish = match candidate
140            .finish_reason
141            .ok_or_else(|| TransformError::missing_metadata("candidate.finish_reason"))?
142        {
143            g::FinishReason::Stop => {
144                if tool_calls.is_empty() {
145                    c::FinishReason::Stop
146                } else {
147                    c::FinishReason::ToolCalls
148                }
149            }
150            g::FinishReason::MaxTokens => c::FinishReason::Length,
151            g::FinishReason::Safety
152            | g::FinishReason::Recitation
153            | g::FinishReason::Language
154            | g::FinishReason::Blocklist
155            | g::FinishReason::ProhibitedContent
156            | g::FinishReason::Spii
157            | g::FinishReason::ImageSafety
158            | g::FinishReason::ImageProhibitedContent
159            | g::FinishReason::ImageRecitation
160            | g::FinishReason::Escalation => c::FinishReason::ContentFilter,
161            g::FinishReason::Unspecified
162            | g::FinishReason::Other
163            | g::FinishReason::MalformedFunctionCall
164            | g::FinishReason::ImageOther
165            | g::FinishReason::NoImage
166            | g::FinishReason::UnexpectedToolCall
167            | g::FinishReason::TooManyToolCalls
168            | g::FinishReason::MissingThoughtSignature
169            | g::FinishReason::MalformedResponse
170            | g::FinishReason::PupLimitedDisabled => {
171                return Err(TransformError::invalid_result(
172                    "candidate.finish_reason",
173                    "Gemini generation did not finish with a representable successful reason",
174                ));
175            }
176        };
177        for (present, field) in [
178            (candidate.safety_ratings.is_some(), "safety_ratings"),
179            (candidate.citation_metadata.is_some(), "citation_metadata"),
180            (
181                candidate.grounding_attributions.is_some(),
182                "grounding_attributions",
183            ),
184            (candidate.grounding_metadata.is_some(), "grounding_metadata"),
185            (candidate.avg_logprobs.is_some(), "avg_logprobs"),
186            (
187                candidate.url_context_metadata.is_some(),
188                "url_context_metadata",
189            ),
190            (candidate.token_count.is_some(), "token_count"),
191            (candidate.finish_message.is_some(), "finish_message"),
192        ] {
193            if present {
194                report.omitted(
195                    format!("candidate.{field}"),
196                    "Gemini detail requires a dedicated target metadata adapter",
197                );
198            }
199        }
200        let mut message =
201            c::ResponseMessage::builder(has_text.then_some(text), None, c::ResponseRole::Assistant)
202                .build();
203        if !reasoning.is_empty() {
204            message.reasoning_content = Some(Some(reasoning));
205        }
206        message.tool_calls = (!tool_calls.is_empty()).then_some(tool_calls);
207        if !details.is_empty() {
208            message.reasoning_details = Some(Some(details));
209        }
210        let logs = candidate
211            .logprobs_result
212            .map(|logs| super::logs::to_chat(logs, &mut report))
213            .map(crate::transform::optional)
214            .transpose()?
215            .flatten();
216        choices.push(c::Choice::builder(finish, index, logs, message).build());
217    }
218    let id = ids
219        .resolve_or_allocate(
220            IdentityRole::Response,
221            SourceIdentity::new(crate::Dialect::Gemini, input.response_id, 0),
222            policy,
223        )
224        .map_err(|error| TransformError::shape("response.identity", error.to_string()))?
225        .emitted_id;
226    let mut out = c::GenerateContentResponseBody::builder(
227        id,
228        choices,
229        created,
230        model,
231        c::CompletionObject::ChatCompletion,
232    )
233    .build();
234    out.usage = usage;
235    if input.prompt_feedback.is_some() {
236        report.omitted("prompt_feedback", "Chat has no prompt-feedback metadata");
237    }
238    if input.model_status.is_some() {
239        report.omitted("model_status", "Chat has no model-status field");
240    }
241    *flow = ids;
242    Ok(Converted { value: out, report })
243}
244
245pub fn openai_to_gemini_response(
246    input: &c::GenerateContentResponseBody,
247) -> Result<Converted<g::GenerateContentResponseBody>, TransformError> {
248    let mut report = Report::default();
249    let usage = input
250        .usage
251        .as_ref()
252        .map(|usage| super::usage::to_gemini(usage, &mut report))
253        .map(crate::transform::optional)
254        .transpose()?
255        .flatten();
256    let mut candidates = Vec::new();
257    let mut seen = std::collections::BTreeSet::new();
258    if input.choices.is_empty() {
259        return Err(TransformError::invalid_result("choices", "no choices"));
260    }
261    for choice in &input.choices {
262        if choice.index < 0 || !seen.insert(choice.index) {
263            return Err(TransformError::invalid_result(
264                "choices.index",
265                "negative or duplicate candidate index",
266            ));
267        }
268        if choice
269            .message
270            .audio
271            .as_ref()
272            .and_then(Option::as_ref)
273            .is_some()
274        {
275            return Err(TransformError::missing_metadata(
276                "audio/legacy response requires adapter facts",
277            ));
278        }
279        let mut parts = Vec::new();
280        if let Some(text) = c::visible_reasoning(
281            &choice.message.reasoning_content,
282            &choice.message.reasoning,
283            &choice.message.reasoning_details,
284        ) {
285            parts.push(g::Part::builder().text(text).thought(true).build());
286        }
287        if let Some(text) = &choice.message.content {
288            parts.push(g::Part::builder().text(text.clone()).build());
289        }
290        if let Some(refusal) = &choice.message.refusal {
291            parts.push(g::Part::builder().text(refusal.clone()).build());
292            report.changed(
293                "refusal",
294                "Gemini refusal represented by text and safety finish reason",
295            );
296        }
297        if let Some(call) = &choice.message.function_call {
298            if choice
299                .message
300                .tool_calls
301                .as_ref()
302                .is_some_and(|calls| !calls.is_empty())
303            {
304                return Err(TransformError::invalid_result(
305                    "function_call",
306                    "legacy/current calls conflict",
307                ));
308            }
309            let args: serde_json::Map<String, serde_json::Value> =
310                serde_json::from_str(&call.arguments).map_err(|error| {
311                    TransformError::invalid_result("function_call.arguments", error.to_string())
312                })?;
313            parts.push(
314                g::Part::builder()
315                    .function_call(
316                        g::FunctionCall::builder(call.name.clone())
317                            .args(args)
318                            .build(),
319                    )
320                    .build(),
321            );
322        }
323        for call in choice.message.tool_calls.iter().flatten() {
324            match call {
325                c::MessageToolCall::Function(call) => {
326                    if call.id.is_empty() {
327                        return Err(TransformError::invalid_result(
328                            "tool_call.id",
329                            "empty tool identity",
330                        ));
331                    }
332                    let args: serde_json::Map<String, serde_json::Value> =
333                        serde_json::from_str(&call.function.arguments).map_err(|error| {
334                            TransformError::invalid_result(
335                                "tool.arguments",
336                                format!("object JSON required: {error}"),
337                            )
338                        })?;
339                    parts.push(
340                        g::Part::builder()
341                            .function_call(
342                                g::FunctionCall::builder(call.function.name.clone())
343                                    .id(call.id.clone())
344                                    .args(args)
345                                    .build(),
346                            )
347                            .build(),
348                    );
349                }
350                c::MessageToolCall::Custom(_) => {
351                    continue;
352                }
353            }
354        }
355        let mut candidate = g::Candidate::builder().build();
356        candidate.content = Some(g::Content::builder().role("model").parts(parts).build());
357        candidate.index = Some(choice.index);
358        candidate.finish_reason = Some(match choice.finish_reason {
359            c::FinishReason::Stop | c::FinishReason::ToolCalls | c::FinishReason::FunctionCall => {
360                g::FinishReason::Stop
361            }
362            c::FinishReason::Length => g::FinishReason::MaxTokens,
363            c::FinishReason::ContentFilter => g::FinishReason::Safety,
364        });
365        candidate.logprobs_result = choice
366            .logprobs
367            .as_ref()
368            .map(|logs| super::logs::to_gemini(logs, &mut report))
369            .map(crate::transform::optional)
370            .transpose()?
371            .flatten();
372        if let Some(annotations) = &choice.message.annotations {
373            let mut sources = Vec::new();
374            for annotation in annotations {
375                let citation = &annotation.url_citation;
376                if citation.start_index < 0 || citation.end_index < citation.start_index {
377                    return Err(TransformError::invalid_result(
378                        "annotations",
379                        "invalid citation range",
380                    ));
381                }
382                sources.push(
383                    g::CitationSource::builder()
384                        .start_index(citation.start_index)
385                        .end_index(citation.end_index)
386                        .uri(citation.url.clone())
387                        .build(),
388                );
389            }
390            candidate.citation_metadata = Some(
391                g::CitationMetadata::builder()
392                    .citation_sources(sources)
393                    .build(),
394            );
395            report.omitted(
396                "annotations.title",
397                "Gemini citation sources have no title field",
398            );
399        }
400        candidates.push(candidate);
401    }
402    let mut out = g::GenerateContentResponseBody::builder().build();
403    out.candidates = Some(candidates);
404    out.usage_metadata = usage;
405    out.response_id = Some(input.id.clone());
406    out.model_version = Some(input.model.clone());
407    Ok(Converted { value: out, report })
408}