systemprompt_wire/gemini/
response.rs1use serde_json::Value;
18use uuid::Uuid;
19
20use super::wire::{GeminiCandidate, GeminiPart, GeminiResponse, GeminiUsageMetadata};
21use crate::canonical::{
22 CanonicalContent, CanonicalResponse, CanonicalStopReason, CanonicalUsage, CodeExecutionOutput,
23 GroundedSource, Grounding,
24};
25use crate::defect::{BodyDefect, buffered_body_defect};
26use crate::error::WireParseError;
27
28const GEMINI_GROUNDING_RELEVANCE: f32 = 0.85;
29
30#[must_use]
31pub fn stop_reason(finish: &str) -> CanonicalStopReason {
32 match finish {
33 "STOP" => CanonicalStopReason::EndTurn,
34 "MAX_TOKENS" => CanonicalStopReason::MaxTokens,
35 "SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" | "IMAGE_SAFETY" => {
36 CanonicalStopReason::Refusal
37 },
38 _ => CanonicalStopReason::Other,
39 }
40}
41
42#[must_use]
43pub(super) fn empty_terminal_message(finish: &str, finish_message: Option<&str>) -> String {
44 finish_message
45 .map(str::trim)
46 .filter(|m| !m.is_empty())
47 .map_or_else(
48 || format!("upstream finished with {finish}"),
49 |detail| format!("upstream finished with {finish}: {detail}"),
50 )
51}
52
53#[must_use]
54pub(super) fn blocked_prompt_message(reason: &str, detail: Option<&str>) -> String {
55 detail.map(str::trim).filter(|m| !m.is_empty()).map_or_else(
56 || format!("upstream blocked the prompt: {reason}"),
57 |detail| format!("upstream blocked the prompt: {reason}: {detail}"),
58 )
59}
60
61pub fn parse_response(
62 value: &Value,
64 fallback_model: &str,
65) -> Result<CanonicalResponse, WireParseError> {
66 let parsed: GeminiResponse =
67 serde_json::from_value(value.clone()).map_err(WireParseError::Gemini)?;
68
69 let id = parsed
70 .response_id
71 .unwrap_or_else(|| format!("msg_{}", Uuid::new_v4().simple()));
72 let model = parsed
73 .model_version
74 .unwrap_or_else(|| fallback_model.to_owned());
75
76 let usage = usage(parsed.usage_metadata);
77 if let Some(reason) = parsed
78 .prompt_feedback
79 .as_ref()
80 .and_then(|f| f.block_reason.as_deref())
81 {
82 let detail = parsed
83 .prompt_feedback
84 .as_ref()
85 .and_then(|f| f.block_reason_message.as_deref());
86 return Err(WireParseError::EmptyTerminal(blocked_prompt_message(
87 reason, detail,
88 )));
89 }
90 let candidate = parsed.candidates.into_iter().next();
91 let raw_finish_reason = candidate.as_ref().and_then(|c| c.finish_reason.clone());
92 let finish_message = candidate.as_ref().and_then(|c| c.finish_message.clone());
93 let stop_reason = raw_finish_reason.as_deref().map(stop_reason);
94 let grounding = candidate.as_ref().and_then(grounding_from_candidate);
95 let parts = candidate.and_then(|c| c.content).map(|c| c.parts);
96 let (content, code_execution) = parts.map_or_else(
97 || (Vec::new(), None),
98 |parts| (parts_to_content(&parts), code_execution(&parts)),
99 );
100 if let (Some(finish), Some(reason)) = (raw_finish_reason.as_deref(), stop_reason)
103 && reason.empty_terminal_is_error()
104 && content.is_empty()
105 {
106 return Err(WireParseError::EmptyTerminal(empty_terminal_message(
107 finish,
108 finish_message.as_deref(),
109 )));
110 }
111
112 let has_tool_use = content
114 .iter()
115 .any(|c| matches!(c, CanonicalContent::ToolUse { .. }));
116 let stop_reason = stop_reason.map(|r| r.with_tool_use(has_tool_use));
117
118 Ok(CanonicalResponse {
119 id,
120 model,
121 content,
122 stop_reason,
123 usage,
124 grounding,
125 code_execution,
126 raw_finish_reason,
127 ..Default::default()
128 })
129}
130
131fn usage(meta: Option<GeminiUsageMetadata>) -> CanonicalUsage {
132 meta.map_or_else(CanonicalUsage::default, |u| CanonicalUsage {
133 input_tokens: u.prompt.saturating_sub(u.cached),
134 output_tokens: u.candidates + u.thoughts,
135 cache_read_tokens: u.cached,
136 cache_creation_tokens: 0,
137 reasoning_tokens: u.thoughts,
138 total_tokens: if u.total > 0 {
139 u.total
140 } else {
141 u.prompt + u.candidates + u.thoughts
142 },
143 })
144}
145
146fn grounding_from_candidate(candidate: &GeminiCandidate) -> Option<Grounding> {
147 let meta = candidate.grounding_metadata.as_ref()?;
148 let sources: Vec<GroundedSource> = meta
149 .grounding_chunks
150 .iter()
151 .filter_map(|c| c.web.as_ref())
152 .filter(|w| !w.uri.is_empty())
153 .map(|w| GroundedSource {
154 uri: w.uri.clone(),
155 title: w.title.clone(),
156 relevance: Some(GEMINI_GROUNDING_RELEVANCE),
157 ..GroundedSource::default()
158 })
159 .collect();
160 if sources.is_empty() && meta.web_search_queries.is_empty() {
161 return None;
162 }
163 Some(Grounding {
164 sources,
165 queries: meta.web_search_queries.clone(),
166 })
167}
168
169fn code_execution(parts: &[GeminiPart]) -> Option<CodeExecutionOutput> {
170 let mut output = CodeExecutionOutput::default();
171 let mut seen = false;
172 for part in parts {
173 match part {
174 GeminiPart::ExecutableCode { executable_code } => {
175 seen = true;
176 output.language.clone_from(&executable_code.language);
177 output.code.clone_from(&executable_code.code);
178 },
179 GeminiPart::CodeExecutionResult {
180 code_execution_result,
181 } => {
182 seen = true;
183 output.result.clone_from(&code_execution_result.output);
184 output.outcome.clone_from(&code_execution_result.outcome);
185 },
186 _ => {},
187 }
188 }
189 seen.then_some(output)
190}
191
192pub(super) fn parts_to_content(parts: &[GeminiPart]) -> Vec<CanonicalContent> {
193 parts
194 .iter()
195 .filter_map(|part| match part {
196 GeminiPart::Text {
197 text,
198 thought: Some(true),
199 thought_signature,
200 } => Some(CanonicalContent::Thinking {
201 text: text.clone(),
202 signature: thought_signature.clone(),
203 id: None,
204 encrypted_content: None,
205 }),
206 GeminiPart::Text { text, .. } if !text.is_empty() => {
207 Some(CanonicalContent::text(text.clone()))
208 },
209 GeminiPart::FunctionCall {
210 function_call,
211 thought_signature,
212 } => Some(CanonicalContent::ToolUse {
213 id: format!("call_{}", Uuid::new_v4().simple()),
214 name: function_call.name.clone(),
215 input: function_call.args.clone(),
216 signature: thought_signature.clone(),
217 cache_control: None,
218 }),
219 _ => None,
220 })
221 .collect()
222}
223
224#[must_use]
225pub fn buffered_defect(value: &Value) -> Option<BodyDefect> {
227 buffered_body_defect(value, "candidates", "usageMetadata")
228}