Skip to main content

gaze_proxy/adapters/
gemini.rs

1use std::borrow::Cow;
2
3use http::Method;
4use serde_json::Value;
5use url::Url;
6
7use crate::adapter::{
8    walk_all_strings, AdapterContract, PiiSurface, ProviderAdapter, SseEvent, SurfaceSyntax,
9};
10
11/// Canonicalizes a protobuf-JSON field name to its lowerCamelCase spelling.
12///
13/// The Generative Language API is a protobuf-JSON surface, and protobuf JSON parsers accept
14/// BOTH the lowerCamelCase name and the original snake_case field name for every field. Matching
15/// the camelCase spelling literally therefore left `system_instruction` — the same field, spelled
16/// the other legal way — with no detection at all, while `systemInstruction` was covered.
17///
18/// Callers match on the canonical form but keep the caller's ORIGINAL key in field paths, so a
19/// path still names the bytes actually on the wire.
20fn canonical_field_name(key: &str) -> Cow<'_, str> {
21    if !key.contains('_') {
22        return Cow::Borrowed(key);
23    }
24    let mut canonical = String::with_capacity(key.len());
25    let mut capitalize_next = false;
26    for character in key.chars() {
27        if character == '_' {
28            capitalize_next = true;
29        } else if capitalize_next {
30            canonical.extend(character.to_uppercase());
31            capitalize_next = false;
32        } else {
33            canonical.push(character);
34        }
35    }
36    Cow::Owned(canonical)
37}
38
39#[derive(Clone, Debug)]
40#[non_exhaustive]
41pub struct GeminiAdapter {
42    upstream: Url,
43}
44
45impl GeminiAdapter {
46    pub fn new(upstream: Url) -> Self {
47        Self { upstream }
48    }
49}
50
51#[async_trait::async_trait]
52impl ProviderAdapter for GeminiAdapter {
53    fn contract(&self) -> AdapterContract<'_> {
54        AdapterContract::legacy()
55    }
56
57    fn name(&self) -> &'static str {
58        "gemini"
59    }
60
61    fn matches_path(&self, method: &Method, path: &str) -> bool {
62        method == Method::POST
63            && path.starts_with("/v1beta/models/")
64            && (path.ends_with(":generateContent")
65                || path.ends_with(":streamGenerateContent")
66                || path.ends_with(":countTokens"))
67    }
68
69    fn upstream_base(&self) -> &Url {
70        &self.upstream
71    }
72
73    fn request_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>> {
74        collect_gemini_surfaces(body, true)
75    }
76
77    fn response_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>> {
78        collect_gemini_surfaces(body, false)
79    }
80
81    fn sse_event_pii_surfaces<'a>(&self, event: &'a mut SseEvent) -> Vec<PiiSurface<'a>> {
82        self.response_pii_surfaces(&mut event.data)
83    }
84
85    /// `generationConfig.responseMimeType = "application/json"` (which a response schema
86    /// requires) makes every answer text part a JSON document.
87    fn requests_json_output(&self, request: &Value) -> bool {
88        let Value::Object(root) = request else {
89            return false;
90        };
91        root.iter()
92            .filter(|(key, _)| canonical_field_name(key) == "generationConfig")
93            .filter_map(|(_, config)| config.as_object())
94            .flatten()
95            .any(|(key, value)| {
96                canonical_field_name(key) == "responseMimeType"
97                    && value
98                        .as_str()
99                        .is_some_and(|mime| mime.eq_ignore_ascii_case("application/json"))
100            })
101    }
102}
103
104/// Surfaces the free-text carriers of a Gemini request, or the model-authored text of a response.
105///
106/// The request allowlist enumerates positions whose contents the MODEL reads, and which are
107/// therefore safe to pseudonymize.
108///
109/// Resource identifiers and opaque blobs are deliberately NOT surfaced — `cachedContent` is a
110/// provider-side resource name, `fileData.fileUri` is fetched by the provider, and
111/// `inlineData.data` is base64 payload. Substituting a token in any of them would silently break
112/// the request rather than protect it, so PII there is caught by the outbound residual re-scan and
113/// fails closed.
114fn collect_gemini_surfaces(body: &mut Value, request: bool) -> Vec<PiiSurface<'_>> {
115    let mut surfaces = Vec::new();
116    if let Value::Object(root) = body {
117        if request {
118            for (key, value) in root {
119                match canonical_field_name(key).as_ref() {
120                    "contents" => {
121                        if let Value::Array(contents) = value {
122                            for (index, content) in contents.iter_mut().enumerate() {
123                                collect_content(&mut surfaces, format!("{key}[{index}]"), content);
124                            }
125                        }
126                    }
127                    "systemInstruction" => {
128                        collect_content(&mut surfaces, key.clone(), value);
129                    }
130                    // Tool and schema declarations: names, descriptions, and enum members are
131                    // all read by the model.
132                    "tools" | "toolConfig" => {
133                        walk_all_strings(&mut surfaces, key.clone(), value);
134                    }
135                    // Covers `stopSequences`, which is matched against generated text. Numeric
136                    // members of this subtree are untouched here and are classified by the
137                    // outbound residual re-scan's non-carrier declaration instead.
138                    "generationConfig" => {
139                        walk_all_strings(&mut surfaces, key.clone(), value);
140                    }
141                    _ => {}
142                }
143            }
144        } else if let Some(Value::Array(candidates)) = root.get_mut("candidates") {
145            for (index, candidate) in candidates.iter_mut().enumerate() {
146                if let Value::Object(candidate) = candidate {
147                    if let Some(content) = candidate.get_mut("content") {
148                        collect_content(
149                            &mut surfaces,
150                            format!("candidates[{index}].content"),
151                            content,
152                        );
153                    }
154                }
155            }
156        }
157    }
158    surfaces
159}
160
161fn collect_content<'a>(surfaces: &mut Vec<PiiSurface<'a>>, prefix: String, value: &'a mut Value) {
162    let Value::Object(content) = value else {
163        return;
164    };
165    let Some((parts_key, Value::Array(parts))) = content
166        .iter_mut()
167        .find(|(key, _)| canonical_field_name(key) == "parts")
168    else {
169        return;
170    };
171    let parts_prefix = format!("{prefix}.{parts_key}");
172    for (index, part) in parts.iter_mut().enumerate() {
173        let Value::Object(part) = part else {
174            continue;
175        };
176        // A thought summary stays prose even when the answer is JSON.
177        let text_syntax = if part.get("thought").and_then(Value::as_bool) == Some(true) {
178            SurfaceSyntax::Text
179        } else {
180            SurfaceSyntax::ModelOutput
181        };
182        for (key, value) in part {
183            let part_prefix = format!("{parts_prefix}[{index}].{key}");
184            match canonical_field_name(key).as_ref() {
185                "text" => {
186                    if let Value::String(text) = value {
187                        surfaces.push(PiiSurface {
188                            field_path: part_prefix,
189                            text,
190                            syntax: text_syntax,
191                        });
192                    }
193                }
194                "functionCall" => {
195                    if let Some((args_key, args)) = value
196                        .as_object_mut()
197                        .and_then(|call| call.iter_mut().find(|(key, _)| *key == "args"))
198                    {
199                        walk_all_strings(surfaces, format!("{part_prefix}.{args_key}"), args);
200                    }
201                }
202                "functionResponse" => {
203                    if let Some((response_key, response)) = value
204                        .as_object_mut()
205                        .and_then(|call| call.iter_mut().find(|(key, _)| *key == "response"))
206                    {
207                        walk_all_strings(
208                            surfaces,
209                            format!("{part_prefix}.{response_key}"),
210                            response,
211                        );
212                    }
213                }
214                _ => {}
215            }
216        }
217    }
218}