gproxy_protocol/transform/generate/gemini_chat/
response.rs1use 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}