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};
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
80/// Surfaces the free-text carriers of a Gemini request, or the model-authored text of a response.
81///
82/// The request allowlist enumerates positions whose contents the MODEL reads, and which are
83/// therefore safe to pseudonymize.
84///
85/// Resource identifiers and opaque blobs are deliberately NOT surfaced — `cachedContent` is a
86/// provider-side resource name, `fileData.fileUri` is fetched by the provider, and
87/// `inlineData.data` is base64 payload. Substituting a token in any of them would silently break
88/// the request rather than protect it, so PII there is caught by the outbound residual re-scan and
89/// fails closed.
90fn collect_gemini_surfaces(body: &mut Value, request: bool) -> Vec<PiiSurface<'_>> {
91    let mut surfaces = Vec::new();
92    if let Value::Object(root) = body {
93        if request {
94            for (key, value) in root {
95                match canonical_field_name(key).as_ref() {
96                    "contents" => {
97                        if let Value::Array(contents) = value {
98                            for (index, content) in contents.iter_mut().enumerate() {
99                                collect_content(&mut surfaces, format!("{key}[{index}]"), content);
100                            }
101                        }
102                    }
103                    "systemInstruction" => {
104                        collect_content(&mut surfaces, key.clone(), value);
105                    }
106                    // Tool and schema declarations: names, descriptions, and enum members are
107                    // all read by the model.
108                    "tools" | "toolConfig" => {
109                        walk_all_strings(&mut surfaces, key.clone(), value);
110                    }
111                    // Covers `stopSequences`, which is matched against generated text. Numeric
112                    // members of this subtree are untouched here and are classified by the
113                    // outbound residual re-scan's non-carrier declaration instead.
114                    "generationConfig" => {
115                        walk_all_strings(&mut surfaces, key.clone(), value);
116                    }
117                    _ => {}
118                }
119            }
120        } else if let Some(Value::Array(candidates)) = root.get_mut("candidates") {
121            for (index, candidate) in candidates.iter_mut().enumerate() {
122                if let Value::Object(candidate) = candidate {
123                    if let Some(content) = candidate.get_mut("content") {
124                        collect_content(
125                            &mut surfaces,
126                            format!("candidates[{index}].content"),
127                            content,
128                        );
129                    }
130                }
131            }
132        }
133    }
134    surfaces
135}
136
137fn collect_content<'a>(surfaces: &mut Vec<PiiSurface<'a>>, prefix: String, value: &'a mut Value) {
138    let Value::Object(content) = value else {
139        return;
140    };
141    let Some((parts_key, Value::Array(parts))) = content
142        .iter_mut()
143        .find(|(key, _)| canonical_field_name(key) == "parts")
144    else {
145        return;
146    };
147    let parts_prefix = format!("{prefix}.{parts_key}");
148    for (index, part) in parts.iter_mut().enumerate() {
149        let Value::Object(part) = part else {
150            continue;
151        };
152        for (key, value) in part {
153            let part_prefix = format!("{parts_prefix}[{index}].{key}");
154            match canonical_field_name(key).as_ref() {
155                "text" => {
156                    if let Value::String(text) = value {
157                        surfaces.push(PiiSurface {
158                            field_path: part_prefix,
159                            text,
160                        });
161                    }
162                }
163                "functionCall" => {
164                    if let Some((args_key, args)) = value
165                        .as_object_mut()
166                        .and_then(|call| call.iter_mut().find(|(key, _)| *key == "args"))
167                    {
168                        walk_all_strings(surfaces, format!("{part_prefix}.{args_key}"), args);
169                    }
170                }
171                "functionResponse" => {
172                    if let Some((response_key, response)) = value
173                        .as_object_mut()
174                        .and_then(|call| call.iter_mut().find(|(key, _)| *key == "response"))
175                    {
176                        walk_all_strings(
177                            surfaces,
178                            format!("{part_prefix}.{response_key}"),
179                            response,
180                        );
181                    }
182                }
183                _ => {}
184            }
185        }
186    }
187}