1use 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#[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#[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#[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#[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#[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 #[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 #[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 #[must_use]
160 pub fn metadata_opt(&self, object: JsonObject) -> Option<ProviderMetadata> {
161 (!object.is_empty()).then(|| self.metadata(object))
162 }
163
164 #[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 #[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 #[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 #[must_use]
190 pub fn generate_id(&self) -> String {
191 self.config.generate_id()
192 }
193
194 #[must_use]
196 pub fn code_execution_call(&mut self, language: Option<&str>, code: Option<&str>) -> ToolCall {
197 self.code_execution_call_with_signature(language, code, None)
198 }
199
200 pub(crate) fn code_execution_call_with_signature(
201 &mut self,
202 language: Option<&str>,
203 code: Option<&str>,
204 signature: Option<&str>,
205 ) -> ToolCall {
206 let id = self.generate_id();
207 self.last_code_execution_id = Some(id.clone());
208 let mut call = ToolCall::new(
209 id,
210 self.code_execution_name(),
211 json!({"language": language, "code": code}).to_string(),
212 );
213 call.provider_executed = true;
214 call.provider_metadata = Some(self.code_execution_metadata(signature));
215 call
216 }
217
218 #[must_use]
220 pub fn code_execution_result(
221 &mut self,
222 outcome: Option<&str>,
223 output: Option<&str>,
224 ) -> ProviderToolResult {
225 self.code_execution_result_with_signature(outcome, output, None)
226 }
227
228 pub(crate) fn code_execution_result_with_signature(
229 &mut self,
230 outcome: Option<&str>,
231 output: Option<&str>,
232 signature: Option<&str>,
233 ) -> ProviderToolResult {
234 let id = self
235 .last_code_execution_id
236 .take()
237 .unwrap_or_else(|| self.generate_id());
238 ProviderToolResult {
239 tool_call_id: id.into(),
240 tool_name: self.code_execution_name().into(),
241 result: json!({"outcome": outcome, "output": output.unwrap_or_default()}),
242 is_error: false,
243 preliminary: false,
244 dynamic: false,
245 provider_metadata: Some(self.code_execution_metadata(signature)),
246 }
247 }
248
249 fn code_execution_metadata(&self, signature: Option<&str>) -> ProviderMetadata {
250 let mut object = JsonObject::new();
251 object.insert(
252 "serverToolType".to_owned(),
253 JsonValue::from("code_execution"),
254 );
255 if let Some(signature) = signature {
256 object.insert("thoughtSignature".to_owned(), JsonValue::from(signature));
257 }
258 self.metadata(object)
259 }
260
261 #[must_use]
263 pub fn function_call(
264 &self,
265 id: Option<&str>,
266 name: &str,
267 args: Option<&JsonValue>,
268 signature: Option<&str>,
269 ) -> ToolCall {
270 let id = id
271 .filter(|id| !id.is_empty())
272 .map_or_else(|| self.generate_id(), str::to_owned);
273 let input = args
274 .cloned()
275 .unwrap_or_else(|| JsonValue::Object(JsonObject::new()))
276 .to_string();
277 let mut call = ToolCall::new(id, self.custom_tool_name(name), input);
278 call.provider_metadata = self.signature_metadata(signature);
279 call
280 }
281
282 #[must_use]
284 pub fn server_tool_call(
285 &mut self,
286 tool_type: Option<&str>,
287 args: Option<&JsonValue>,
288 id: Option<&str>,
289 signature: Option<&str>,
290 ) -> ToolCall {
291 let tool_type = tool_type.unwrap_or("unknown");
292 let id = id
293 .filter(|id| !id.is_empty())
294 .map_or_else(|| self.generate_id(), str::to_owned);
295 self.last_server_tool_call_id = Some(id.clone());
296 let mut call = ToolCall::new(
297 id.clone(),
298 format!("server:{tool_type}"),
299 args.cloned()
300 .unwrap_or_else(|| JsonValue::Object(JsonObject::new()))
301 .to_string(),
302 );
303 call.provider_executed = true;
304 call.dynamic = true;
305 let mut object = JsonObject::new();
306 if let Some(signature) = signature {
307 object.insert("thoughtSignature".to_owned(), JsonValue::from(signature));
308 }
309 object.insert("serverToolCallId".to_owned(), JsonValue::from(id));
310 object.insert("serverToolType".to_owned(), JsonValue::from(tool_type));
311 call.provider_metadata = Some(self.metadata(object));
312 call
313 }
314
315 #[must_use]
317 pub fn server_tool_response(&mut self, response: &JsonValue) -> ProviderToolResult {
318 let tool_type = response
319 .get("toolType")
320 .and_then(JsonValue::as_str)
321 .unwrap_or("unknown");
322 let id = self
323 .last_server_tool_call_id
324 .take()
325 .or_else(|| {
326 response
327 .get("id")
328 .and_then(JsonValue::as_str)
329 .map(str::to_owned)
330 })
331 .unwrap_or_else(|| self.generate_id());
332 ProviderToolResult {
333 tool_call_id: id.into(),
334 tool_name: format!("server:{tool_type}").into(),
335 result: response.clone(),
336 is_error: false,
337 preliminary: false,
338 dynamic: true,
339 provider_metadata: None,
340 }
341 }
342
343 pub fn inline_file(
349 &self,
350 mime_type: &str,
351 data: &str,
352 thought: bool,
353 signature: Option<&str>,
354 ) -> Result<Content, ProviderError> {
355 let bytes = decode_base64(data)?;
356 let media_type = MediaType::new(mime_type);
357 Ok(if thought {
358 Content::ReasoningFile {
359 data: FileData::Bytes { data: bytes },
360 media_type,
361 provider_metadata: self.signature_metadata(signature),
362 }
363 } else {
364 Content::File {
365 data: FileData::Bytes { data: bytes },
366 media_type,
367 filename: None,
368 provider_metadata: self.signature_metadata(signature),
369 }
370 })
371 }
372
373 pub fn map_parts(&mut self, parts: &[Part]) -> Result<Vec<Content>, ProviderError> {
379 let mut content: Vec<Content> = Vec::new();
380 for part in parts {
381 let signature = part.thought_signature.as_deref();
382 if let Some(code) = &part.executable_code {
383 content.push(Content::ToolCall(self.code_execution_call_with_signature(
384 code.language.as_deref(),
385 code.code.as_deref(),
386 signature,
387 )));
388 }
389 if let Some(result) = &part.code_execution_result {
390 content.push(Content::ToolResult(
391 self.code_execution_result_with_signature(
392 result.outcome.as_deref(),
393 result.output.as_deref(),
394 signature,
395 ),
396 ));
397 }
398 if let Some(text) = &part.text {
399 if text.is_empty() {
400 if let Some(signature) = signature {
401 attach_signature(&mut content, self.signature_metadata(Some(signature)));
402 }
403 } else if part.thought == Some(true) {
404 content.push(Content::Reasoning {
405 text: text.clone(),
406 provider_metadata: self.signature_metadata(signature),
407 });
408 } else {
409 content.push(Content::Text {
410 text: text.clone(),
411 provider_metadata: self.signature_metadata(signature),
412 });
413 }
414 }
415 if let Some(call) = &part.function_call
416 && let Some(name) = &call.name
417 {
418 content.push(Content::ToolCall(self.function_call(
419 call.id.as_deref(),
420 name,
421 call.args.as_ref(),
422 signature,
423 )));
424 }
425 if let Some(inline) = &part.inline_data {
426 content.push(self.inline_file(
427 &inline.mime_type,
428 &inline.data,
429 part.thought == Some(true),
430 signature,
431 )?);
432 }
433 if let Some(call) = &part.tool_call {
434 content.push(Content::ToolCall(self.server_tool_call(
435 call.tool_type.as_deref(),
436 call.args.as_ref(),
437 call.id.as_deref(),
438 signature,
439 )));
440 }
441 if let Some(response) = &part.tool_response {
442 content.push(Content::ToolResult(self.server_tool_response(response)));
443 }
444 }
445 Ok(content)
446 }
447
448 #[must_use]
450 pub fn sources(&self, chunks: &[GroundingChunk]) -> Vec<Source> {
451 let mut sources = Vec::new();
452 for chunk in chunks {
453 if let Some(web) = &chunk.web
454 && let Some(uri) = &web.uri
455 {
456 sources.push(Source::Url {
457 id: self.generate_id(),
458 url: uri.clone(),
459 title: web.title.clone(),
460 provider_metadata: None,
461 });
462 }
463 if let Some(image) = &chunk.image
464 && let Some(uri) = image.source_uri.as_ref().or(image.image_uri.as_ref())
465 {
466 sources.push(Source::Url {
467 id: self.generate_id(),
468 url: uri.clone(),
469 title: image.title.clone(),
470 provider_metadata: None,
471 });
472 }
473 if let Some(context) = &chunk.retrieved_context {
474 if let Some(uri) = &context.uri {
475 if uri.starts_with("http://") || uri.starts_with("https://") {
476 sources.push(Source::Url {
477 id: self.generate_id(),
478 url: uri.clone(),
479 title: context.title.clone(),
480 provider_metadata: None,
481 });
482 } else {
483 sources.push(Source::Document {
484 id: self.generate_id(),
485 media_type: MediaType::new(document_media_type(uri)),
486 title: context
487 .title
488 .clone()
489 .unwrap_or_else(|| "Unknown Document".to_owned()),
490 filename: last_segment(uri),
491 provider_metadata: None,
492 });
493 }
494 } else if let Some(store) = &context.file_search_store {
495 sources.push(Source::Document {
496 id: self.generate_id(),
497 media_type: MediaType::new("application/octet-stream"),
498 title: context
499 .title
500 .clone()
501 .unwrap_or_else(|| "Unknown Document".to_owned()),
502 filename: last_segment(store),
503 provider_metadata: None,
504 });
505 }
506 }
507 if let Some(maps) = &chunk.maps
508 && let Some(uri) = &maps.uri
509 {
510 sources.push(Source::Url {
511 id: self.generate_id(),
512 url: uri.clone(),
513 title: maps.title.clone(),
514 provider_metadata: None,
515 });
516 }
517 }
518 sources
519 }
520
521 #[must_use]
523 pub fn response_metadata(
524 &self,
525 response: &GenerateContentResponse,
526 candidate: Option<&Candidate>,
527 raw_usage: Option<&JsonValue>,
528 ) -> ProviderMetadata {
529 let mut object = JsonObject::new();
530 object.insert(
531 "promptFeedback".to_owned(),
532 response.prompt_feedback.clone().unwrap_or(JsonValue::Null),
533 );
534 object.insert(
535 "groundingMetadata".to_owned(),
536 candidate
537 .and_then(|candidate| candidate.grounding_metadata.clone())
538 .unwrap_or(JsonValue::Null),
539 );
540 object.insert(
541 "urlContextMetadata".to_owned(),
542 candidate
543 .and_then(|candidate| candidate.url_context_metadata.clone())
544 .unwrap_or(JsonValue::Null),
545 );
546 object.insert(
547 "safetyRatings".to_owned(),
548 candidate
549 .and_then(|candidate| candidate.safety_ratings.clone())
550 .unwrap_or(JsonValue::Null),
551 );
552 object.insert(
553 "usageMetadata".to_owned(),
554 raw_usage.cloned().unwrap_or(JsonValue::Null),
555 );
556 object.insert(
557 "finishMessage".to_owned(),
558 candidate
559 .and_then(|candidate| candidate.finish_message.clone())
560 .map_or(JsonValue::Null, JsonValue::from),
561 );
562 object.insert(
563 "serviceTier".to_owned(),
564 response
565 .usage_metadata
566 .as_ref()
567 .and_then(|usage| usage.service_tier.clone())
568 .map_or(JsonValue::Null, JsonValue::from),
569 );
570 self.metadata(object)
571 }
572}
573
574fn attach_signature(content: &mut [Content], metadata: Option<ProviderMetadata>) {
576 let Some(last) = content.last_mut() else {
577 return;
578 };
579 match last {
580 Content::Text {
581 provider_metadata, ..
582 }
583 | Content::Reasoning {
584 provider_metadata, ..
585 }
586 | Content::File {
587 provider_metadata, ..
588 }
589 | Content::ReasoningFile {
590 provider_metadata, ..
591 } => merge_metadata(provider_metadata, metadata),
592 Content::ToolCall(call) => merge_metadata(&mut call.provider_metadata, metadata),
593 _ => {}
594 }
595}
596
597fn merge_metadata(target: &mut Option<ProviderMetadata>, extra: Option<ProviderMetadata>) {
598 let Some(extra) = extra else {
599 return;
600 };
601 match target {
602 Some(existing) => {
603 for (key, object) in extra {
604 existing.entry(key).or_default().extend(object);
605 }
606 }
607 None => *target = Some(extra),
608 }
609}
610
611#[must_use]
613pub fn raw_usage(raw: Option<&JsonValue>) -> Option<JsonObject> {
614 raw?.get("usageMetadata")?.as_object().cloned()
615}