1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Instant;
4
5use axum::body::Bytes;
6use axum::extract::State;
7use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
8use axum::response::{IntoResponse, Response};
9use axum::routing::get;
10use axum::Router;
11use gaze::{token_shape, CleanDocument, Pipeline, RawDocument, Scope, Session};
12use reqwest::Client;
13use serde::Serialize;
14use serde_json::Value;
15use tokio::net::TcpListener;
16use tokio::sync::RwLock;
17use url::Url;
18
19use crate::adapter::{ProviderAdapter, SseEvent};
20use crate::error::ProxyError;
21use crate::ProxyConfig;
22
23const SESSION_HEADER: &str = "x-gaze-session-id";
24
25#[derive(Clone)]
26struct AppState {
27 config: Arc<ProxyConfig>,
28 pipeline: Arc<Pipeline>,
29 client: Client,
30 sessions: Arc<RwLock<HashMap<String, SessionEntry>>>,
31 started_at: Instant,
32}
33
34struct SessionEntry {
35 session: Arc<Session>,
36 expires_at: Instant,
37}
38
39#[derive(Debug, Clone, Serialize)]
40#[non_exhaustive]
41pub struct HealthSnapshot {
42 pub uptime_secs: u64,
43 pub bind: String,
44 pub adapters: Vec<AdapterSnapshot>,
45}
46
47#[derive(Debug, Clone, Serialize)]
48#[non_exhaustive]
49pub struct AdapterSnapshot {
50 pub name: &'static str,
51 pub upstream: String,
52}
53
54pub async fn serve(config: ProxyConfig, pipeline: Arc<Pipeline>) -> Result<(), ProxyError> {
55 let bind = config.bind;
56 let state = AppState {
57 config: Arc::new(config),
58 pipeline,
59 client: Client::new(),
60 sessions: Arc::new(RwLock::new(HashMap::new())),
61 started_at: Instant::now(),
62 };
63 let app = Router::new()
64 .route("/_gaze_proxy/healthz", get(healthz))
65 .fallback(proxy)
66 .with_state(state);
67 let listener = TcpListener::bind(bind)
68 .await
69 .map_err(|source| ProxyError::Server { source })?;
70 axum::serve(listener, app)
71 .await
72 .map_err(|source| ProxyError::Server { source })
73}
74
75async fn healthz(State(state): State<AppState>) -> impl IntoResponse {
76 axum::Json(health_snapshot(&state))
77}
78
79fn health_snapshot(state: &AppState) -> HealthSnapshot {
80 HealthSnapshot {
81 uptime_secs: state.started_at.elapsed().as_secs(),
82 bind: state.config.bind.to_string(),
83 adapters: state
84 .config
85 .adapters
86 .iter()
87 .map(|adapter| AdapterSnapshot {
88 name: adapter.name(),
89 upstream: adapter.upstream_base().to_string(),
90 })
91 .collect(),
92 }
93}
94
95async fn proxy(
96 State(state): State<AppState>,
97 method: Method,
98 uri: Uri,
99 headers: HeaderMap,
100 body: Bytes,
101) -> Response {
102 match proxy_inner(state, method, uri, headers, body).await {
103 Ok(response) => response,
104 Err(err) => proxy_error_response(err),
105 }
106}
107
108async fn proxy_inner(
109 state: AppState,
110 method: Method,
111 uri: Uri,
112 headers: HeaderMap,
113 body: Bytes,
114) -> Result<Response, ProxyError> {
115 if body.len() as u64 > state.config.body_limit_bytes {
116 return Err(ProxyError::BodyTooLarge {
117 limit_bytes: state.config.body_limit_bytes,
118 });
119 }
120 let path = uri.path();
121 let adapter = state
122 .config
123 .adapters
124 .iter()
125 .find(|adapter| adapter.matches_path(&method, path))
126 .cloned()
127 .ok_or_else(|| ProxyError::AdapterNotFound {
128 path: path.to_string(),
129 method: method.clone(),
130 })?;
131 let session = session_for(&state, &headers).await?;
132 let mut json: Value =
133 serde_json::from_slice(&body).map_err(|source| ProxyError::InvalidJson { source })?;
134 redact_surfaces(
135 &state.pipeline,
136 &session,
137 adapter.request_pii_surfaces(&mut json),
138 )?;
139
140 let upstream_url = upstream_url(adapter.upstream_base(), &uri)?;
141 let mut request = state
142 .client
143 .request(method.clone(), upstream_url.clone())
144 .json(&json);
145 request = forward_headers(request, &headers);
146 let upstream = request
147 .send()
148 .await
149 .map_err(|source| ProxyError::UpstreamUnreachable {
150 url: upstream_url.clone(),
151 source,
152 })?;
153
154 let status = upstream.status();
155 let upstream_headers = upstream.headers().clone();
156 let content_type = upstream_headers
157 .get(reqwest::header::CONTENT_TYPE)
158 .and_then(|value| value.to_str().ok())
159 .unwrap_or_default()
160 .to_ascii_lowercase();
161 let bytes = upstream
162 .bytes()
163 .await
164 .map_err(|source| ProxyError::UpstreamUnreachable {
165 url: upstream_url,
166 source,
167 })?;
168
169 let body = if content_type.contains("text/event-stream") {
170 transform_sse(&adapter, &session, &bytes)?
171 } else {
172 let mut response_json: Value =
173 serde_json::from_slice(&bytes).map_err(|source| ProxyError::InvalidJson { source })?;
174 restore_surfaces(&session, adapter.response_pii_surfaces(&mut response_json));
175 serde_json::to_vec(&response_json).map_err(|source| ProxyError::InvalidJson { source })?
176 };
177
178 let mut response = Response::builder().status(status);
179 for (name, value) in upstream_headers {
180 if let Some(name) = name {
181 if name == axum::http::header::CONTENT_LENGTH {
182 continue;
183 }
184 response = response.header(name, value);
185 }
186 }
187 response
188 .body(axum::body::Body::from(body))
189 .map_err(|source| ProxyError::DaemonConfig {
190 detail: source.to_string(),
191 })
192}
193
194async fn session_for(state: &AppState, headers: &HeaderMap) -> Result<Arc<Session>, ProxyError> {
195 let now = Instant::now();
196 let id = headers
197 .get(SESSION_HEADER)
198 .and_then(|value| value.to_str().ok())
199 .filter(|value| !value.trim().is_empty())
200 .map(str::to_string)
201 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
202
203 {
204 let sessions = state.sessions.read().await;
205 if let Some(entry) = sessions.get(&id) {
206 if entry.expires_at > now {
207 return Ok(entry.session.clone());
208 }
209 }
210 }
211
212 let session = Arc::new(
213 Session::new(Scope::Conversation(id.clone()))
214 .map_err(|source| ProxyError::Pipeline { source })?,
215 );
216 let mut sessions = state.sessions.write().await;
217 sessions.retain(|_, entry| entry.expires_at > now);
218 sessions.insert(
219 id,
220 SessionEntry {
221 session: session.clone(),
222 expires_at: now + state.config.session_ttl,
223 },
224 );
225 Ok(session)
226}
227
228fn redact_surfaces(
229 pipeline: &Pipeline,
230 session: &Session,
231 surfaces: Vec<crate::adapter::PiiSurface<'_>>,
232) -> Result<(), ProxyError> {
233 for surface in surfaces {
234 let clean = pipeline
235 .redact(session, RawDocument::Text(surface.text.clone()))
236 .map_err(|source| ProxyError::Pipeline { source })?;
237 if let CleanDocument::Text(text) = clean {
238 *surface.text = text;
239 }
240 }
241 Ok(())
242}
243
244fn restore_surfaces(session: &Session, surfaces: Vec<crate::adapter::PiiSurface<'_>>) {
245 for surface in surfaces {
246 *surface.text = restore_text(session, surface.text);
247 }
248}
249
250fn restore_text(session: &Session, clean: &str) -> String {
251 let mut restored = String::new();
252 let mut last = 0;
253 for matched in token_shape::pattern().find_iter(clean) {
254 restored.push_str(&clean[last..matched.start()]);
255 if let Some(raw) = session.restore(matched.as_str()) {
256 restored.push_str(&raw);
257 } else {
258 restored.push_str(matched.as_str());
259 }
260 last = matched.end();
261 }
262 restored.push_str(&clean[last..]);
263 restored
264}
265
266fn transform_sse(
267 adapter: &Arc<dyn ProviderAdapter>,
268 session: &Session,
269 bytes: &[u8],
270) -> Result<Vec<u8>, ProxyError> {
271 let text = std::str::from_utf8(bytes).map_err(|err| ProxyError::SsePartialFrame {
272 reason: err.to_string(),
273 })?;
274 let mut out = String::new();
275 for frame in text.split("\n\n") {
276 if frame.trim().is_empty() {
277 continue;
278 }
279 let mut event_name = None;
280 let mut data_lines = Vec::new();
281 for line in frame.lines() {
282 if let Some(rest) = line.strip_prefix("event:") {
283 event_name = Some(rest.trim().to_string());
284 } else if let Some(rest) = line.strip_prefix("data:") {
285 data_lines.push(rest.trim_start());
286 } else {
287 out.push_str(line);
288 out.push('\n');
289 }
290 }
291 let data = data_lines.join("\n");
292 if data == "[DONE]" {
293 if let Some(name) = event_name {
294 out.push_str("event: ");
295 out.push_str(&name);
296 out.push('\n');
297 }
298 out.push_str("data: [DONE]\n\n");
299 continue;
300 }
301 let mut event = SseEvent {
302 event: event_name.clone(),
303 data: serde_json::from_str(&data)
304 .map_err(|source| ProxyError::InvalidJson { source })?,
305 };
306 restore_surfaces(session, adapter.sse_event_pii_surfaces(&mut event));
307 if let Some(name) = event_name {
308 out.push_str("event: ");
309 out.push_str(&name);
310 out.push('\n');
311 }
312 out.push_str("data: ");
313 out.push_str(
314 &serde_json::to_string(&event.data)
315 .map_err(|source| ProxyError::InvalidJson { source })?,
316 );
317 out.push_str("\n\n");
318 }
319 Ok(out.into_bytes())
320}
321
322fn upstream_url(base: &Url, uri: &Uri) -> Result<Url, ProxyError> {
323 let path = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
324 base.join(path).map_err(|err| ProxyError::DaemonConfig {
325 detail: err.to_string(),
326 })
327}
328
329fn forward_headers(
330 mut request: reqwest::RequestBuilder,
331 headers: &HeaderMap,
332) -> reqwest::RequestBuilder {
333 const FORWARDED: &[&str] = &[
334 "authorization",
335 "x-api-key",
336 "anthropic-version",
337 "openai-beta",
338 "content-type",
339 ];
340 for name in FORWARDED {
341 let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
342 continue;
343 };
344 if let Some(value) = headers.get(&header_name) {
345 request = request.header(name.to_string(), value.clone());
346 }
347 }
348 request
349}
350
351fn proxy_error_response(err: ProxyError) -> Response {
352 let status = match err {
353 ProxyError::AdapterNotFound { .. } => StatusCode::NOT_FOUND,
354 ProxyError::BodyTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
355 ProxyError::InvalidJson { .. } => StatusCode::BAD_REQUEST,
356 ProxyError::UpstreamUnreachable { .. } => StatusCode::BAD_GATEWAY,
357 _ => StatusCode::INTERNAL_SERVER_ERROR,
358 };
359 let body = serde_json::json!({
360 "error": proxy_error_name(&err),
361 });
362 (
363 status,
364 [(
365 axum::http::header::CONTENT_TYPE,
366 HeaderValue::from_static("application/json"),
367 )],
368 body.to_string(),
369 )
370 .into_response()
371}
372
373fn proxy_error_name(err: &ProxyError) -> &'static str {
374 match err {
375 ProxyError::UpstreamUnreachable { .. } => "UpstreamUnreachable",
376 ProxyError::AdapterNotFound { .. } => "AdapterNotFound",
377 ProxyError::BodyTooLarge { .. } => "BodyTooLarge",
378 ProxyError::InvalidJson { .. } => "InvalidJson",
379 ProxyError::SsePartialFrame { .. } => "SsePartialFrame",
380 ProxyError::Pipeline { .. } => "Pipeline",
381 ProxyError::Server { .. } => "Server",
382 ProxyError::DaemonAlreadyRunning { .. } => "DaemonAlreadyRunning",
383 ProxyError::DaemonNotRunning => "DaemonNotRunning",
384 ProxyError::DaemonPidfileStale { .. } => "DaemonPidfileStale",
385 ProxyError::DaemonIo { .. } => "DaemonIo",
386 ProxyError::DaemonConfig { .. } => "DaemonConfig",
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn restore_text_leaves_unknown_tokens() {
396 let session = Session::new(Scope::Conversation("test".to_string())).unwrap();
397 assert_eq!(restore_text(&session, "hello <Email_1>"), "hello <Email_1>");
398 }
399
400 #[test]
401 fn upstream_url_preserves_path_and_query() {
402 let base = Url::parse("https://api.openai.com").unwrap();
403 let uri: Uri = "/v1/chat/completions?x=1".parse().unwrap();
404 assert_eq!(
405 upstream_url(&base, &uri).unwrap().as_str(),
406 "https://api.openai.com/v1/chat/completions?x=1"
407 );
408 }
409
410 #[test]
411 fn health_lists_adapters() {
412 let config = ProxyConfig::new(
413 "127.0.0.1:0".parse().unwrap(),
414 vec![Arc::new(crate::adapters::OpenAiAdapter::new(
415 Url::parse("https://api.openai.com").unwrap(),
416 ))],
417 );
418 let state = AppState {
419 config: Arc::new(config),
420 pipeline: Arc::new(Pipeline::builder().build().unwrap()),
421 client: Client::new(),
422 sessions: Arc::new(RwLock::new(HashMap::new())),
423 started_at: Instant::now(),
424 };
425 assert_eq!(health_snapshot(&state).adapters[0].name, "openai");
426 }
427}