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