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::Continuation => {
151                return Err(crate::transform::generate::gemini_controls::continuation_error());
152            }
153            g::FinishReason::MaxTokens => c::FinishReason::Length,
154            g::FinishReason::Safety
155            | g::FinishReason::Recitation
156            | g::FinishReason::Language
157            | g::FinishReason::Blocklist
158            | g::FinishReason::ProhibitedContent
159            | g::FinishReason::Spii
160            | g::FinishReason::ImageSafety
161            | g::FinishReason::ImageProhibitedContent
162            | g::FinishReason::ImageRecitation
163            | g::FinishReason::Escalation => c::FinishReason::ContentFilter,
164            g::FinishReason::Unspecified
165            | g::FinishReason::Other
166            | g::FinishReason::MalformedFunctionCall
167            | g::FinishReason::ImageOther
168            | g::FinishReason::NoImage
169            | g::FinishReason::UnexpectedToolCall
170            | g::FinishReason::TooManyToolCalls
171            | g::FinishReason::MissingThoughtSignature
172            | g::FinishReason::MalformedResponse
173            | g::FinishReason::PupLimitedDisabled => {
174                return Err(TransformError::invalid_result(
175                    "candidate.finish_reason",
176                    "Gemini generation did not finish with a representable successful reason",
177                ));
178            }
179        };
180        for (present, field) in [
181            (candidate.safety_ratings.is_some(), "safety_ratings"),
182            (candidate.citation_metadata.is_some(), "citation_metadata"),
183            (
184                candidate.grounding_attributions.is_some(),
185                "grounding_attributions",
186            ),
187            (candidate.grounding_metadata.is_some(), "grounding_metadata"),
188            (candidate.avg_logprobs.is_some(), "avg_logprobs"),
189            (
190                candidate.url_context_metadata.is_some(),
191                "url_context_metadata",
192            ),
193            (candidate.token_count.is_some(), "token_count"),
194            (candidate.finish_message.is_some(), "finish_message"),
195        ] {
196            if present {
197                report.omitted(
198                    format!("candidate.{field}"),
199                    "Gemini detail requires a dedicated target metadata adapter",
200                );
201            }
202        }
203        let mut message =
204            c::ResponseMessage::builder(has_text.then_some(text), None, c::ResponseRole::Assistant)
205                .build();
206        if !reasoning.is_empty() {
207            message.reasoning_content = Some(Some(reasoning));
208        }
209        message.tool_calls = (!tool_calls.is_empty()).then_some(tool_calls);
210        if !details.is_empty() {
211            message.reasoning_details = Some(Some(details));
212        }
213        let logs = candidate
214            .logprobs_result
215            .map(|logs| super::logs::to_chat(logs, &mut report))
216            .map(crate::transform::optional)
217            .transpose()?
218            .flatten();
219        choices.push(c::Choice::builder(finish, index, logs, message).build());
220    }
221    let id = ids
222        .resolve_or_allocate(
223            IdentityRole::Response,
224            SourceIdentity::new(crate::Dialect::Gemini, input.response_id, 0),
225            policy,
226        )
227        .map_err(|error| TransformError::shape("response.identity", error.to_string()))?
228        .emitted_id;
229    let mut out = c::GenerateContentResponseBody::builder(
230        id,
231        choices,
232        created,
233        model,
234        c::CompletionObject::ChatCompletion,
235    )
236    .build();
237    out.usage = usage;
238    if input.prompt_feedback.is_some() {
239        report.omitted("prompt_feedback", "Chat has no prompt-feedback metadata");
240    }
241    if input.model_status.is_some() {
242        report.omitted("model_status", "Chat has no model-status field");
243    }
244    *flow = ids;
245    Ok(Converted { value: out, report })
246}
247
248pub fn openai_to_gemini_response(
249    input: &c::GenerateContentResponseBody,
250) -> Result<Converted<g::GenerateContentResponseBody>, TransformError> {
251    let mut report = Report::default();
252    let usage = input
253        .usage
254        .as_ref()
255        .map(|usage| super::usage::to_gemini(usage, &mut report))
256        .map(crate::transform::optional)
257        .transpose()?
258        .flatten();
259    let mut candidates = Vec::new();
260    let mut seen = std::collections::BTreeSet::new();
261    if input.choices.is_empty() {
262        return Err(TransformError::invalid_result("choices", "no choices"));
263    }
264    for choice in &input.choices {
265        if choice.index < 0 || !seen.insert(choice.index) {
266            return Err(TransformError::invalid_result(
267                "choices.index",
268                "negative or duplicate candidate index",
269            ));
270        }
271        if choice
272            .message
273            .audio
274            .as_ref()
275            .and_then(Option::as_ref)
276            .is_some()
277        {
278            return Err(TransformError::missing_metadata(
279                "audio/legacy response requires adapter facts",
280            ));
281        }
282        let mut parts = Vec::new();
283        if let Some(text) = c::visible_reasoning(
284            &choice.message.reasoning_content,
285            &choice.message.reasoning,
286            &choice.message.reasoning_details,
287        ) {
288            parts.push(g::Part::builder().text(text).thought(true).build());
289        }
290        if let Some(text) = &choice.message.content {
291            parts.push(g::Part::builder().text(text.clone()).build());
292        }
293        if let Some(refusal) = &choice.message.refusal {
294            parts.push(g::Part::builder().text(refusal.clone()).build());
295            report.changed(
296                "refusal",
297                "Gemini refusal represented by text and safety finish reason",
298            );
299        }
300        if let Some(call) = &choice.message.function_call {
301            if choice
302                .message
303                .tool_calls
304                .as_ref()
305                .is_some_and(|calls| !calls.is_empty())
306            {
307                return Err(TransformError::invalid_result(
308                    "function_call",
309                    "legacy/current calls conflict",
310                ));
311            }
312            let args: serde_json::Map<String, serde_json::Value> =
313                serde_json::from_str(&call.arguments).map_err(|error| {
314                    TransformError::invalid_result("function_call.arguments", error.to_string())
315                })?;
316            parts.push(
317                g::Part::builder()
318                    .function_call(
319                        g::FunctionCall::builder(call.name.clone())
320                            .args(args)
321                            .build(),
322                    )
323                    .build(),
324            );
325        }
326        for call in choice.message.tool_calls.iter().flatten() {
327            match call {
328                c::MessageToolCall::Function(call) => {
329                    if call.id.is_empty() {
330                        return Err(TransformError::invalid_result(
331                            "tool_call.id",
332                            "empty tool identity",
333                        ));
334                    }
335                    let args: serde_json::Map<String, serde_json::Value> =
336                        serde_json::from_str(&call.function.arguments).map_err(|error| {
337                            TransformError::invalid_result(
338                                "tool.arguments",
339                                format!("object JSON required: {error}"),
340                            )
341                        })?;
342                    parts.push(
343                        g::Part::builder()
344                            .function_call(
345                                g::FunctionCall::builder(call.function.name.clone())
346                                    .id(call.id.clone())
347                                    .args(args)
348                                    .build(),
349                            )
350                            .build(),
351                    );
352                }
353                c::MessageToolCall::Custom(_) => {
354                    continue;
355                }
356            }
357        }
358        let mut candidate = g::Candidate::builder().build();
359        candidate.content = Some(g::Content::builder().role("model").parts(parts).build());
360        candidate.index = Some(choice.index);
361        candidate.finish_reason = Some(match choice.finish_reason {
362            c::FinishReason::Stop | c::FinishReason::ToolCalls | c::FinishReason::FunctionCall => {
363                g::FinishReason::Stop
364            }
365            c::FinishReason::Length => g::FinishReason::MaxTokens,
366            c::FinishReason::ContentFilter => g::FinishReason::Safety,
367        });
368        candidate.logprobs_result = choice
369            .logprobs
370            .as_ref()
371            .map(|logs| super::logs::to_gemini(logs, &mut report))
372            .map(crate::transform::optional)
373            .transpose()?
374            .flatten();
375        if let Some(annotations) = &choice.message.annotations {
376            let mut sources = Vec::new();
377            for annotation in annotations {
378                let citation = &annotation.url_citation;
379                if citation.start_index < 0 || citation.end_index < citation.start_index {
380                    return Err(TransformError::invalid_result(
381                        "annotations",
382                        "invalid citation range",
383                    ));
384                }
385                sources.push(
386                    g::CitationSource::builder()
387                        .start_index(citation.start_index)
388                        .end_index(citation.end_index)
389                        .uri(citation.url.clone())
390                        .build(),
391                );
392            }
393            candidate.citation_metadata = Some(
394                g::CitationMetadata::builder()
395                    .citation_sources(sources)
396                    .build(),
397            );
398            report.omitted(
399                "annotations.title",
400                "Gemini citation sources have no title field",
401            );
402        }
403        candidates.push(candidate);
404    }
405    let mut out = g::GenerateContentResponseBody::builder().build();
406    out.candidates = Some(candidates);
407    out.usage_metadata = usage;
408    out.response_id = Some(input.id.clone());
409    out.model_version = Some(input.model.clone());
410    Ok(Converted { value: out, report })
411}