Skip to main content

systemprompt_wire/gemini/
response.rs

1//! Parses a buffered Gemini reply into a [`CanonicalResponse`].
2//!
3//! # Usage counts
4//!
5//! Gemini reports `thoughtsTokenCount` beside `candidatesTokenCount`, so a
6//! turn that spent its whole budget thinking arrives with `candidates: 0` and
7//! looks free; `CanonicalUsage` requires `output_tokens` to include reasoning,
8//! so the two are summed and thoughts kept on their own for reporting.
9//! `cachedContentTokenCount` is a *subset* of `promptTokenCount`, while
10//! `CanonicalUsage::input_tokens` is exclusive of cache reads, so it is
11//! subtracted -- left in, the cached slice bills at the input rate as well as
12//! the cache-read rate. The wire total already counts it once and is kept.
13//!
14//! Copyright (c) systemprompt.io — Business Source License 1.1.
15//! See <https://systemprompt.io> for licensing details.
16
17use 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    // JSON: Gemini `generateContent` response body; upstream JSON is the contract.
63    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    // Why: a candidate that ends on SAFETY or MALFORMED_FUNCTION_CALL with no
101    // parts is the provider cutting the turn off, not the model saying nothing.
102    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    // Why: Gemini reports `finishReason: STOP` even for a `functionCall` candidate.
113    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]
225// JSON: Gemini `generateContent` response body; upstream JSON is the contract.
226pub fn buffered_defect(value: &Value) -> Option<BodyDefect> {
227    buffered_body_defect(value, "candidates", "usageMetadata")
228}