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::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}