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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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
538fn 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#[must_use]
577pub fn raw_usage(raw: Option<&JsonValue>) -> Option<JsonObject> {
578 raw?.get("usageMetadata")?.as_object().cloned()
579}