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