Skip to main content

ferrin_google/
output.rs

1//! Mapping of `generateContent` responses to specification content, usage,
2//! finish reasons and provider metadata.
3
4use base64::Engine;
5use bytes::Bytes;
6use ferrin_provider_util::tool_name_mapping::ToolNameMapping;
7use ferrin_spec::Content;
8use ferrin_spec::FileData;
9use ferrin_spec::FinishReason;
10use ferrin_spec::FinishReasonKind;
11use ferrin_spec::JsonObject;
12use ferrin_spec::JsonValue;
13use ferrin_spec::MediaType;
14use ferrin_spec::ProviderMetadata;
15use ferrin_spec::ToolCall;
16use ferrin_spec::Usage;
17use ferrin_spec::error::InvalidResponseDataError;
18use ferrin_spec::error::ProviderError;
19use ferrin_spec::language_model::InputTokens;
20use ferrin_spec::language_model::OutputTokens;
21use ferrin_spec::language_model::ProviderToolResult;
22use ferrin_spec::language_model::Source;
23use serde_json::json;
24
25use crate::api_types::Candidate;
26use crate::api_types::GenerateContentResponse;
27use crate::api_types::GroundingChunk;
28use crate::api_types::Part;
29use crate::api_types::UsageMetadata;
30use crate::config::CANONICAL_OPTIONS_KEY;
31use crate::config::SharedConfig;
32use crate::prepare_tools::CODE_EXECUTION_TOOL_NAME;
33
34/// Maps a Gemini finish reason.
35#[must_use]
36pub fn map_finish_reason(reason: Option<&str>, has_tool_calls: bool) -> FinishReason {
37    let unified = match reason {
38        Some("STOP") if has_tool_calls => FinishReasonKind::ToolCalls,
39        Some("STOP") => FinishReasonKind::Stop,
40        Some("MAX_TOKENS") => FinishReasonKind::Length,
41        Some(
42            "IMAGE_SAFETY" | "RECITATION" | "SAFETY" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII",
43        ) => FinishReasonKind::ContentFilter,
44        Some("MALFORMED_FUNCTION_CALL") => FinishReasonKind::Error,
45        _ => FinishReasonKind::Other,
46    };
47    match reason {
48        Some(raw) => FinishReason::with_raw(unified, raw),
49        None => FinishReason::new(unified),
50    }
51}
52
53/// Converts `usageMetadata`; `raw` is the original object.
54#[must_use]
55pub fn convert_usage(usage: Option<&UsageMetadata>, raw: Option<JsonObject>) -> Usage {
56    let Some(usage) = usage else {
57        return Usage::default();
58    };
59    let prompt = usage.prompt_token_count.unwrap_or_default();
60    let candidates = usage.candidates_token_count.unwrap_or_default();
61    let cached = usage.cached_content_token_count.unwrap_or_default();
62    let thoughts = usage.thoughts_token_count.unwrap_or_default();
63    Usage {
64        input: InputTokens {
65            total: Some(prompt),
66            no_cache: Some(prompt.saturating_sub(cached)),
67            cache_read: Some(cached),
68            cache_write: None,
69        },
70        output: OutputTokens {
71            total: Some(candidates + thoughts),
72            text: Some(candidates),
73            reasoning: Some(thoughts),
74        },
75        raw,
76    }
77}
78
79/// Whether `content` contains a tool call executed by the client.
80#[must_use]
81pub fn has_client_tool_calls(content: &[Content]) -> bool {
82    content
83        .iter()
84        .any(|part| matches!(part, Content::ToolCall(call) if !call.provider_executed))
85}
86
87/// Media type of a retrieved document by file extension.
88#[must_use]
89pub fn document_media_type(uri: &str) -> &'static str {
90    let lower = uri.to_ascii_lowercase();
91    if lower.ends_with(".pdf") {
92        "application/pdf"
93    } else if lower.ends_with(".txt") {
94        "text/plain"
95    } else if lower.ends_with(".docx") {
96        "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
97    } else if lower.ends_with(".doc") {
98        "application/msword"
99    } else if lower.ends_with(".md") || lower.ends_with(".markdown") {
100        "text/markdown"
101    } else {
102        "application/octet-stream"
103    }
104}
105
106fn last_segment(path: &str) -> Option<String> {
107    path.rsplit('/')
108        .next()
109        .filter(|segment| !segment.is_empty())
110        .map(str::to_owned)
111}
112
113fn decode_base64(data: &str) -> Result<Bytes, ProviderError> {
114    base64::engine::general_purpose::STANDARD
115        .decode(data)
116        .map(Bytes::from)
117        .map_err(|error| {
118            ProviderError::InvalidResponseData(Box::new(InvalidResponseDataError::new(
119                format!("invalid base64 inline data: {error}"),
120                JsonValue::Null,
121            )))
122        })
123}
124
125/// Maps response parts to content and builds provider metadata.
126#[derive(Debug, Clone)]
127pub struct OutputMapper {
128    config: SharedConfig,
129    mapping: ToolNameMapping,
130    last_code_execution_id: Option<String>,
131    last_server_tool_call_id: Option<String>,
132}
133
134impl OutputMapper {
135    /// Creates a mapper.
136    #[must_use]
137    pub fn new(config: SharedConfig, mapping: ToolNameMapping) -> Self {
138        Self {
139            config,
140            mapping,
141            last_code_execution_id: None,
142            last_server_tool_call_id: None,
143        }
144    }
145
146    /// Wraps `object` under the `google` key (and the configured name when it
147    /// differs).
148    #[must_use]
149    pub fn metadata(&self, object: JsonObject) -> ProviderMetadata {
150        let mut metadata = ProviderMetadata::new();
151        if self.config.options_key() != CANONICAL_OPTIONS_KEY {
152            metadata.insert(self.config.options_key().to_owned(), object.clone());
153        }
154        metadata.insert(CANONICAL_OPTIONS_KEY.to_owned(), object);
155        metadata
156    }
157
158    /// Like [`Self::metadata`], `None` for an empty object.
159    #[must_use]
160    pub fn metadata_opt(&self, object: JsonObject) -> Option<ProviderMetadata> {
161        (!object.is_empty()).then(|| self.metadata(object))
162    }
163
164    /// Metadata carrying only a thought signature.
165    #[must_use]
166    pub fn signature_metadata(&self, signature: Option<&str>) -> Option<ProviderMetadata> {
167        signature.map(|signature| {
168            let mut object = JsonObject::new();
169            object.insert("thoughtSignature".to_owned(), JsonValue::from(signature));
170            self.metadata(object)
171        })
172    }
173
174    /// Custom name of the code execution tool.
175    #[must_use]
176    pub fn code_execution_name(&self) -> String {
177        self.mapping
178            .to_custom_tool_name(CODE_EXECUTION_TOOL_NAME)
179            .to_owned()
180    }
181
182    /// Custom name of a function.
183    #[must_use]
184    pub fn custom_tool_name(&self, provider_name: &str) -> String {
185        self.mapping.to_custom_tool_name(provider_name).to_owned()
186    }
187
188    /// Generates an id.
189    #[must_use]
190    pub fn generate_id(&self) -> String {
191        self.config.generate_id()
192    }
193
194    /// Maps a code execution part to a provider-executed tool call.
195    #[must_use]
196    pub fn code_execution_call(&mut self, language: Option<&str>, code: Option<&str>) -> ToolCall {
197        let id = self.generate_id();
198        self.last_code_execution_id = Some(id.clone());
199        let mut call = ToolCall::new(
200            id,
201            self.code_execution_name(),
202            json!({"language": language, "code": code}).to_string(),
203        );
204        call.provider_executed = true;
205        call
206    }
207
208    /// Maps a code execution result.
209    #[must_use]
210    pub fn code_execution_result(
211        &mut self,
212        outcome: Option<&str>,
213        output: Option<&str>,
214    ) -> ProviderToolResult {
215        let id = self
216            .last_code_execution_id
217            .take()
218            .unwrap_or_else(|| self.generate_id());
219        ProviderToolResult {
220            tool_call_id: id.into(),
221            tool_name: self.code_execution_name().into(),
222            result: json!({"outcome": outcome, "output": output.unwrap_or_default()}),
223            is_error: false,
224            preliminary: false,
225            dynamic: false,
226            provider_metadata: None,
227        }
228    }
229
230    /// Maps a function call part.
231    #[must_use]
232    pub fn function_call(
233        &self,
234        id: Option<&str>,
235        name: &str,
236        args: Option<&JsonValue>,
237        signature: Option<&str>,
238    ) -> ToolCall {
239        let id = id
240            .filter(|id| !id.is_empty())
241            .map_or_else(|| self.generate_id(), str::to_owned);
242        let input = args
243            .cloned()
244            .unwrap_or_else(|| JsonValue::Object(JsonObject::new()))
245            .to_string();
246        let mut call = ToolCall::new(id, self.custom_tool_name(name), input);
247        call.provider_metadata = self.signature_metadata(signature);
248        call
249    }
250
251    /// Maps a server-side tool call part.
252    #[must_use]
253    pub fn server_tool_call(
254        &mut self,
255        tool_type: Option<&str>,
256        args: Option<&JsonValue>,
257        id: Option<&str>,
258        signature: Option<&str>,
259    ) -> ToolCall {
260        let tool_type = tool_type.unwrap_or("unknown");
261        let id = id
262            .filter(|id| !id.is_empty())
263            .map_or_else(|| self.generate_id(), str::to_owned);
264        self.last_server_tool_call_id = Some(id.clone());
265        let mut call = ToolCall::new(
266            id.clone(),
267            format!("server:{tool_type}"),
268            args.cloned()
269                .unwrap_or_else(|| JsonValue::Object(JsonObject::new()))
270                .to_string(),
271        );
272        call.provider_executed = true;
273        call.dynamic = true;
274        let mut object = JsonObject::new();
275        if let Some(signature) = signature {
276            object.insert("thoughtSignature".to_owned(), JsonValue::from(signature));
277        }
278        object.insert("serverToolCallId".to_owned(), JsonValue::from(id));
279        object.insert("serverToolType".to_owned(), JsonValue::from(tool_type));
280        call.provider_metadata = Some(self.metadata(object));
281        call
282    }
283
284    /// Maps a server-side tool response part.
285    #[must_use]
286    pub fn server_tool_response(&mut self, response: &JsonValue) -> ProviderToolResult {
287        let tool_type = response
288            .get("toolType")
289            .and_then(JsonValue::as_str)
290            .unwrap_or("unknown");
291        let id = self
292            .last_server_tool_call_id
293            .take()
294            .or_else(|| {
295                response
296                    .get("id")
297                    .and_then(JsonValue::as_str)
298                    .map(str::to_owned)
299            })
300            .unwrap_or_else(|| self.generate_id());
301        ProviderToolResult {
302            tool_call_id: id.into(),
303            tool_name: format!("server:{tool_type}").into(),
304            result: response.clone(),
305            is_error: false,
306            preliminary: false,
307            dynamic: true,
308            provider_metadata: None,
309        }
310    }
311
312    /// Decodes an inline data part into a file or reasoning file.
313    ///
314    /// # Errors
315    ///
316    /// Returns [`ProviderError::InvalidResponseData`] for invalid base64.
317    pub fn inline_file(
318        &self,
319        mime_type: &str,
320        data: &str,
321        thought: bool,
322        signature: Option<&str>,
323    ) -> Result<Content, ProviderError> {
324        let bytes = decode_base64(data)?;
325        let media_type = MediaType::new(mime_type);
326        Ok(if thought {
327            Content::ReasoningFile {
328                data: FileData::Bytes { data: bytes },
329                media_type,
330                provider_metadata: self.signature_metadata(signature),
331            }
332        } else {
333            Content::File {
334                data: FileData::Bytes { data: bytes },
335                media_type,
336                filename: None,
337                provider_metadata: self.signature_metadata(signature),
338            }
339        })
340    }
341
342    /// Maps the parts of a non-streaming candidate.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`ProviderError::InvalidResponseData`] for invalid inline data.
347    pub fn map_parts(&mut self, parts: &[Part]) -> Result<Vec<Content>, ProviderError> {
348        let mut content: Vec<Content> = Vec::new();
349        for part in parts {
350            let signature = part.thought_signature.as_deref();
351            if let Some(code) = &part.executable_code {
352                content.push(Content::ToolCall(
353                    self.code_execution_call(code.language.as_deref(), code.code.as_deref()),
354                ));
355            }
356            if let Some(result) = &part.code_execution_result {
357                content.push(Content::ToolResult(self.code_execution_result(
358                    result.outcome.as_deref(),
359                    result.output.as_deref(),
360                )));
361            }
362            if let Some(text) = &part.text {
363                if text.is_empty() {
364                    if let Some(signature) = signature {
365                        attach_signature(&mut content, self.signature_metadata(Some(signature)));
366                    }
367                } else if part.thought == Some(true) {
368                    content.push(Content::Reasoning {
369                        text: text.clone(),
370                        provider_metadata: self.signature_metadata(signature),
371                    });
372                } else {
373                    content.push(Content::Text {
374                        text: text.clone(),
375                        provider_metadata: self.signature_metadata(signature),
376                    });
377                }
378            }
379            if let Some(call) = &part.function_call
380                && let Some(name) = &call.name
381            {
382                content.push(Content::ToolCall(self.function_call(
383                    call.id.as_deref(),
384                    name,
385                    call.args.as_ref(),
386                    signature,
387                )));
388            }
389            if let Some(inline) = &part.inline_data {
390                content.push(self.inline_file(
391                    &inline.mime_type,
392                    &inline.data,
393                    part.thought == Some(true),
394                    signature,
395                )?);
396            }
397            if let Some(call) = &part.tool_call {
398                content.push(Content::ToolCall(self.server_tool_call(
399                    call.tool_type.as_deref(),
400                    call.args.as_ref(),
401                    call.id.as_deref(),
402                    signature,
403                )));
404            }
405            if let Some(response) = &part.tool_response {
406                content.push(Content::ToolResult(self.server_tool_response(response)));
407            }
408        }
409        Ok(content)
410    }
411
412    /// Maps grounding chunks to sources.
413    #[must_use]
414    pub fn sources(&self, chunks: &[GroundingChunk]) -> Vec<Source> {
415        let mut sources = Vec::new();
416        for chunk in chunks {
417            if let Some(web) = &chunk.web
418                && let Some(uri) = &web.uri
419            {
420                sources.push(Source::Url {
421                    id: self.generate_id(),
422                    url: uri.clone(),
423                    title: web.title.clone(),
424                    provider_metadata: None,
425                });
426            }
427            if let Some(image) = &chunk.image
428                && let Some(uri) = image.source_uri.as_ref().or(image.image_uri.as_ref())
429            {
430                sources.push(Source::Url {
431                    id: self.generate_id(),
432                    url: uri.clone(),
433                    title: image.title.clone(),
434                    provider_metadata: None,
435                });
436            }
437            if let Some(context) = &chunk.retrieved_context {
438                if let Some(uri) = &context.uri {
439                    if uri.starts_with("http://") || uri.starts_with("https://") {
440                        sources.push(Source::Url {
441                            id: self.generate_id(),
442                            url: uri.clone(),
443                            title: context.title.clone(),
444                            provider_metadata: None,
445                        });
446                    } else {
447                        sources.push(Source::Document {
448                            id: self.generate_id(),
449                            media_type: MediaType::new(document_media_type(uri)),
450                            title: context
451                                .title
452                                .clone()
453                                .unwrap_or_else(|| "Unknown Document".to_owned()),
454                            filename: last_segment(uri),
455                            provider_metadata: None,
456                        });
457                    }
458                } else if let Some(store) = &context.file_search_store {
459                    sources.push(Source::Document {
460                        id: self.generate_id(),
461                        media_type: MediaType::new("application/octet-stream"),
462                        title: context
463                            .title
464                            .clone()
465                            .unwrap_or_else(|| "Unknown Document".to_owned()),
466                        filename: last_segment(store),
467                        provider_metadata: None,
468                    });
469                }
470            }
471            if let Some(maps) = &chunk.maps
472                && let Some(uri) = &maps.uri
473            {
474                sources.push(Source::Url {
475                    id: self.generate_id(),
476                    url: uri.clone(),
477                    title: maps.title.clone(),
478                    provider_metadata: None,
479                });
480            }
481        }
482        sources
483    }
484
485    /// Result-level provider metadata.
486    #[must_use]
487    pub fn response_metadata(
488        &self,
489        response: &GenerateContentResponse,
490        candidate: Option<&Candidate>,
491        raw_usage: Option<&JsonValue>,
492    ) -> ProviderMetadata {
493        let mut object = JsonObject::new();
494        object.insert(
495            "promptFeedback".to_owned(),
496            response.prompt_feedback.clone().unwrap_or(JsonValue::Null),
497        );
498        object.insert(
499            "groundingMetadata".to_owned(),
500            candidate
501                .and_then(|candidate| candidate.grounding_metadata.clone())
502                .unwrap_or(JsonValue::Null),
503        );
504        object.insert(
505            "urlContextMetadata".to_owned(),
506            candidate
507                .and_then(|candidate| candidate.url_context_metadata.clone())
508                .unwrap_or(JsonValue::Null),
509        );
510        object.insert(
511            "safetyRatings".to_owned(),
512            candidate
513                .and_then(|candidate| candidate.safety_ratings.clone())
514                .unwrap_or(JsonValue::Null),
515        );
516        object.insert(
517            "usageMetadata".to_owned(),
518            raw_usage.cloned().unwrap_or(JsonValue::Null),
519        );
520        object.insert(
521            "finishMessage".to_owned(),
522            candidate
523                .and_then(|candidate| candidate.finish_message.clone())
524                .map_or(JsonValue::Null, JsonValue::from),
525        );
526        object.insert(
527            "serviceTier".to_owned(),
528            response
529                .usage_metadata
530                .as_ref()
531                .and_then(|usage| usage.service_tier.clone())
532                .map_or(JsonValue::Null, JsonValue::from),
533        );
534        self.metadata(object)
535    }
536}
537
538/// Attaches `metadata` to the last content part that can carry it.
539fn attach_signature(content: &mut [Content], metadata: Option<ProviderMetadata>) {
540    let Some(last) = content.last_mut() else {
541        return;
542    };
543    match last {
544        Content::Text {
545            provider_metadata, ..
546        }
547        | Content::Reasoning {
548            provider_metadata, ..
549        }
550        | Content::File {
551            provider_metadata, ..
552        }
553        | Content::ReasoningFile {
554            provider_metadata, ..
555        } => merge_metadata(provider_metadata, metadata),
556        Content::ToolCall(call) => merge_metadata(&mut call.provider_metadata, metadata),
557        _ => {}
558    }
559}
560
561fn merge_metadata(target: &mut Option<ProviderMetadata>, extra: Option<ProviderMetadata>) {
562    let Some(extra) = extra else {
563        return;
564    };
565    match target {
566        Some(existing) => {
567            for (key, object) in extra {
568                existing.entry(key).or_default().extend(object);
569            }
570        }
571        None => *target = Some(extra),
572    }
573}
574
575/// `usageMetadata` object of a raw response.
576#[must_use]
577pub fn raw_usage(raw: Option<&JsonValue>) -> Option<JsonObject> {
578    raw?.get("usageMetadata")?.as_object().cloned()
579}