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