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