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    SurfaceSyntax,
8};
9
10#[derive(Clone, Debug)]
11#[non_exhaustive]
12pub struct OpenAiAdapter {
13    upstream: Url,
14}
15
16impl OpenAiAdapter {
17    pub fn new(upstream: Url) -> Self {
18        Self { upstream }
19    }
20}
21
22#[async_trait::async_trait]
23impl ProviderAdapter for OpenAiAdapter {
24    fn name(&self) -> &'static str {
25        "openai"
26    }
27
28    fn matches_path(&self, method: &Method, path: &str) -> bool {
29        method == Method::POST
30            && matches!(
31                path,
32                "/v1/chat/completions" | "/v1/completions" | "/v1/responses"
33            )
34    }
35
36    fn upstream_base(&self) -> &Url {
37        &self.upstream
38    }
39
40    /// Surfaces the free-text carriers of an OpenAI request.
41    ///
42    /// This allowlist enumerates positions whose contents the MODEL reads, and which are
43    /// therefore safe to pseudonymize: whatever token is substituted round-trips through the
44    /// provider and back through restore.
45    ///
46    /// Resource identifiers are deliberately NOT surfaced — `tool_call_id`, `previous_response_id`,
47    /// and `model` are resolved provider-side, so substituting a token would silently break the
48    /// request rather than protect it. PII in those positions is caught by the outbound residual
49    /// re-scan and fails closed, which tells the adopter their identifier carries PII instead of
50    /// handing them a broken request that looks fine.
51    fn request_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>> {
52        let mut surfaces = Vec::new();
53        if let Value::Object(root) = body {
54            for (key, value) in root {
55                match key.as_str() {
56                    "system" => push_string(&mut surfaces, "system", value, SurfaceSyntax::Text),
57                    "prompt" => {
58                        push_text_blocks(&mut surfaces, "prompt", value, SurfaceSyntax::Text);
59                    }
60                    "instructions" => {
61                        push_string(&mut surfaces, "instructions", value, SurfaceSyntax::Text);
62                    }
63                    "messages" => {
64                        if let Value::Array(messages) = value {
65                            for (index, message) in messages.iter_mut().enumerate() {
66                                collect_message_surfaces(
67                                    &mut surfaces,
68                                    format!("messages[{index}]"),
69                                    message,
70                                );
71                            }
72                        }
73                    }
74                    "input" => {
75                        push_text_blocks(&mut surfaces, "input", value, SurfaceSyntax::Text);
76                    }
77                    // The documented end-user identifier. Free-form adopter text and a
78                    // first-class PII carrier in practice; opaque to the provider beyond abuse
79                    // monitoring, so pseudonymizing it is safe.
80                    "user" => push_string(&mut surfaces, "user", value, SurfaceSyntax::Text),
81                    // Adopter-attached key/value pairs, echoed back verbatim.
82                    "metadata" => walk_all_strings(&mut surfaces, "metadata".to_string(), value),
83                    // Stop sequences are matched against generated text, so they are model-visible.
84                    "stop" => walk_all_strings(&mut surfaces, "stop".to_string(), value),
85                    // Tool and schema declarations: names, descriptions, and enum members are all
86                    // read by the model. `walk_all_strings` covers the whole declaration subtree.
87                    "tools" => walk_all_strings(&mut surfaces, "tools".to_string(), value),
88                    "functions" => walk_all_strings(&mut surfaces, "functions".to_string(), value),
89                    "response_format" => {
90                        walk_all_strings(&mut surfaces, "response_format".to_string(), value);
91                    }
92                    "prediction" => {
93                        walk_all_strings(&mut surfaces, "prediction".to_string(), value);
94                    }
95                    _ => {}
96                }
97            }
98        }
99        surfaces
100    }
101
102    fn response_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>> {
103        let mut surfaces = Vec::new();
104        if let Value::Object(root) = body {
105            for (key, value) in root {
106                match key.as_str() {
107                    "choices" => {
108                        if let Value::Array(choices) = value {
109                            for (index, choice) in choices.iter_mut().enumerate() {
110                                if let Value::Object(choice) = choice {
111                                    for (choice_key, choice_value) in choice {
112                                        match choice_key.as_str() {
113                                            "message" => collect_message_surfaces(
114                                                &mut surfaces,
115                                                format!("choices[{index}].message"),
116                                                choice_value,
117                                            ),
118                                            "delta" => collect_message_surfaces(
119                                                &mut surfaces,
120                                                format!("choices[{index}].delta"),
121                                                choice_value,
122                                            ),
123                                            "text" => push_string(
124                                                &mut surfaces,
125                                                format!("choices[{index}].text"),
126                                                choice_value,
127                                                SurfaceSyntax::ModelOutput,
128                                            ),
129                                            _ => {}
130                                        }
131                                    }
132                                }
133                            }
134                        }
135                    }
136                    "output" => collect_output_surfaces(&mut surfaces, value),
137                    _ => {}
138                }
139            }
140        }
141        surfaces
142    }
143
144    fn sse_event_pii_surfaces<'a>(&self, event: &'a mut SseEvent) -> Vec<PiiSurface<'a>> {
145        self.response_pii_surfaces(&mut event.data)
146    }
147
148    /// Chat Completions selects JSON output with `response_format`, the Responses API with
149    /// `text.format`; both take `json_object` or `json_schema`.
150    fn requests_json_output(&self, request: &Value) -> bool {
151        let is_json = |format: &Value| {
152            matches!(
153                format.get("type").and_then(Value::as_str),
154                Some("json_object" | "json_schema")
155            )
156        };
157        request.get("response_format").is_some_and(is_json)
158            || request
159                .get("text")
160                .and_then(|text| text.get("format"))
161                .is_some_and(is_json)
162    }
163}
164
165/// Surfaces every string of the Responses API `output` items, typed by what carries it: the
166/// `arguments` of a `function_call` or `mcp_call` item is a serialized JSON document, and the
167/// `output_text` of a `message` item is model output.
168fn collect_output_surfaces<'a>(surfaces: &mut Vec<PiiSurface<'a>>, output: &'a mut Value) {
169    let Value::Array(items) = output else {
170        walk_all_strings(surfaces, "output".to_string(), output);
171        return;
172    };
173    for (index, item) in items.iter_mut().enumerate() {
174        let prefix = format!("output[{index}]");
175        let kind = item.get("type").and_then(Value::as_str);
176        let is_call = matches!(kind, Some("function_call" | "mcp_call"));
177        let is_message = kind == Some("message");
178        let Value::Object(fields) = item else {
179            walk_all_strings(surfaces, prefix, item);
180            continue;
181        };
182        for (key, value) in fields {
183            let path = format!("{prefix}.{key}");
184            match key.as_str() {
185                "arguments" if is_call && value.is_string() => {
186                    push_string(surfaces, path, value, SurfaceSyntax::Json);
187                }
188                "content" if is_message => collect_output_content(surfaces, path, value),
189                _ => walk_all_strings(surfaces, path, value),
190            }
191        }
192    }
193}
194
195fn collect_output_content<'a>(
196    surfaces: &mut Vec<PiiSurface<'a>>,
197    prefix: String,
198    content: &'a mut Value,
199) {
200    let Value::Array(parts) = content else {
201        walk_all_strings(surfaces, prefix, content);
202        return;
203    };
204    for (index, part) in parts.iter_mut().enumerate() {
205        let part_prefix = format!("{prefix}[{index}]");
206        let is_output_text = part.get("type").and_then(Value::as_str) == Some("output_text");
207        let Value::Object(fields) = part else {
208            walk_all_strings(surfaces, part_prefix, part);
209            continue;
210        };
211        for (key, value) in fields {
212            let path = format!("{part_prefix}.{key}");
213            if is_output_text && key == "text" && value.is_string() {
214                push_string(surfaces, path, value, SurfaceSyntax::ModelOutput);
215            } else {
216                walk_all_strings(surfaces, path, value);
217            }
218        }
219    }
220}
221
222fn collect_message_surfaces<'a>(
223    surfaces: &mut Vec<PiiSurface<'a>>,
224    prefix: String,
225    message: &'a mut Value,
226) {
227    let Value::Object(map) = message else {
228        return;
229    };
230    for (key, value) in map {
231        match key.as_str() {
232            "content" => push_text_blocks(
233                surfaces,
234                &format!("{prefix}.content"),
235                value,
236                SurfaceSyntax::ModelOutput,
237            ),
238            "tool_results" => push_text_blocks(
239                surfaces,
240                &format!("{prefix}.tool_results"),
241                value,
242                SurfaceSyntax::Text,
243            ),
244            // The participant name on a message. A person's name by definition, and read by the
245            // model, so it is pseudonymized rather than rejected.
246            "name" => push_string(
247                surfaces,
248                format!("{prefix}.name"),
249                value,
250                SurfaceSyntax::Text,
251            ),
252            "tool_calls" => {
253                if let Value::Array(tool_calls) = value {
254                    for (index, tool_call) in tool_calls.iter_mut().enumerate() {
255                        if let Value::Object(tool_call) = tool_call {
256                            if let Some(Value::Object(function)) = tool_call.get_mut("function") {
257                                // A serialized JSON object, in requests and responses alike.
258                                if let Some(args) = function.get_mut("arguments") {
259                                    push_string(
260                                        surfaces,
261                                        format!("{prefix}.tool_calls[{index}].function.arguments"),
262                                        args,
263                                        SurfaceSyntax::Json,
264                                    );
265                                }
266                            }
267                        }
268                    }
269                }
270            }
271            _ => {}
272        }
273    }
274}