Skip to main content

gaze_proxy/adapters/
openai.rs

1use http::Method;
2use serde_json::Value;
3use url::Url;
4
5use crate::adapter::{
6    push_string, push_text_blocks, walk_all_strings, PiiSurface, ProviderAdapter, SseEvent,
7};
8
9#[derive(Clone, Debug)]
10#[non_exhaustive]
11pub struct OpenAiAdapter {
12    upstream: Url,
13}
14
15impl OpenAiAdapter {
16    pub fn new(upstream: Url) -> Self {
17        Self { upstream }
18    }
19}
20
21#[async_trait::async_trait]
22impl ProviderAdapter for OpenAiAdapter {
23    fn name(&self) -> &'static str {
24        "openai"
25    }
26
27    fn matches_path(&self, method: &Method, path: &str) -> bool {
28        method == Method::POST
29            && matches!(
30                path,
31                "/v1/chat/completions" | "/v1/completions" | "/v1/responses"
32            )
33    }
34
35    fn upstream_base(&self) -> &Url {
36        &self.upstream
37    }
38
39    /// Surfaces the free-text carriers of an OpenAI request.
40    ///
41    /// This allowlist enumerates positions whose contents the MODEL reads, and which are
42    /// therefore safe to pseudonymize: whatever token is substituted round-trips through the
43    /// provider and back through restore.
44    ///
45    /// Resource identifiers are deliberately NOT surfaced — `tool_call_id`, `previous_response_id`,
46    /// and `model` are resolved provider-side, so substituting a token would silently break the
47    /// request rather than protect it. PII in those positions is caught by the outbound residual
48    /// re-scan and fails closed, which tells the adopter their identifier carries PII instead of
49    /// handing them a broken request that looks fine.
50    fn request_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>> {
51        let mut surfaces = Vec::new();
52        if let Value::Object(root) = body {
53            for (key, value) in root {
54                match key.as_str() {
55                    "system" => push_string(&mut surfaces, "system", value),
56                    "prompt" => push_text_blocks(&mut surfaces, "prompt", value),
57                    "instructions" => push_string(&mut surfaces, "instructions", value),
58                    "messages" => {
59                        if let Value::Array(messages) = value {
60                            for (index, message) in messages.iter_mut().enumerate() {
61                                collect_message_surfaces(
62                                    &mut surfaces,
63                                    format!("messages[{index}]"),
64                                    message,
65                                );
66                            }
67                        }
68                    }
69                    "input" => push_text_blocks(&mut surfaces, "input", value),
70                    // The documented end-user identifier. Free-form adopter text and a
71                    // first-class PII carrier in practice; opaque to the provider beyond abuse
72                    // monitoring, so pseudonymizing it is safe.
73                    "user" => push_string(&mut surfaces, "user", value),
74                    // Adopter-attached key/value pairs, echoed back verbatim.
75                    "metadata" => walk_all_strings(&mut surfaces, "metadata".to_string(), value),
76                    // Stop sequences are matched against generated text, so they are model-visible.
77                    "stop" => walk_all_strings(&mut surfaces, "stop".to_string(), value),
78                    // Tool and schema declarations: names, descriptions, and enum members are all
79                    // read by the model. `walk_all_strings` covers the whole declaration subtree.
80                    "tools" => walk_all_strings(&mut surfaces, "tools".to_string(), value),
81                    "functions" => walk_all_strings(&mut surfaces, "functions".to_string(), value),
82                    "response_format" => {
83                        walk_all_strings(&mut surfaces, "response_format".to_string(), value);
84                    }
85                    "prediction" => {
86                        walk_all_strings(&mut surfaces, "prediction".to_string(), value);
87                    }
88                    _ => {}
89                }
90            }
91        }
92        surfaces
93    }
94
95    fn response_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>> {
96        let mut surfaces = Vec::new();
97        if let Value::Object(root) = body {
98            for (key, value) in root {
99                match key.as_str() {
100                    "choices" => {
101                        if let Value::Array(choices) = value {
102                            for (index, choice) in choices.iter_mut().enumerate() {
103                                if let Value::Object(choice) = choice {
104                                    for (choice_key, choice_value) in choice {
105                                        match choice_key.as_str() {
106                                            "message" => collect_message_surfaces(
107                                                &mut surfaces,
108                                                format!("choices[{index}].message"),
109                                                choice_value,
110                                            ),
111                                            "delta" => collect_message_surfaces(
112                                                &mut surfaces,
113                                                format!("choices[{index}].delta"),
114                                                choice_value,
115                                            ),
116                                            "text" => push_string(
117                                                &mut surfaces,
118                                                format!("choices[{index}].text"),
119                                                choice_value,
120                                            ),
121                                            _ => {}
122                                        }
123                                    }
124                                }
125                            }
126                        }
127                    }
128                    "output" => walk_all_strings(&mut surfaces, "output".to_string(), value),
129                    _ => {}
130                }
131            }
132        }
133        surfaces
134    }
135
136    fn sse_event_pii_surfaces<'a>(&self, event: &'a mut SseEvent) -> Vec<PiiSurface<'a>> {
137        self.response_pii_surfaces(&mut event.data)
138    }
139}
140
141fn collect_message_surfaces<'a>(
142    surfaces: &mut Vec<PiiSurface<'a>>,
143    prefix: String,
144    message: &'a mut Value,
145) {
146    let Value::Object(map) = message else {
147        return;
148    };
149    for (key, value) in map {
150        match key.as_str() {
151            "content" => push_text_blocks(surfaces, &format!("{prefix}.content"), value),
152            "tool_results" => push_text_blocks(surfaces, &format!("{prefix}.tool_results"), value),
153            // The participant name on a message. A person's name by definition, and read by the
154            // model, so it is pseudonymized rather than rejected.
155            "name" => push_string(surfaces, format!("{prefix}.name"), value),
156            "tool_calls" => {
157                if let Value::Array(tool_calls) = value {
158                    for (index, tool_call) in tool_calls.iter_mut().enumerate() {
159                        if let Value::Object(tool_call) = tool_call {
160                            if let Some(Value::Object(function)) = tool_call.get_mut("function") {
161                                if let Some(args) = function.get_mut("arguments") {
162                                    push_string(
163                                        surfaces,
164                                        format!("{prefix}.tool_calls[{index}].function.arguments"),
165                                        args,
166                                    );
167                                }
168                            }
169                        }
170                    }
171                }
172            }
173            _ => {}
174        }
175    }
176}