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 response = response.header(name, value);
182 }
183 }
184 response
185 .body(axum::body::Body::from(body))
186 .map_err(|source| ProxyError::DaemonConfig {
187 detail: source.to_string(),
188 })
189}
190
191async fn session_for(state: &AppState, headers: &HeaderMap) -> Result<Arc<Session>, ProxyError> {
192 let now = Instant::now();
193 let id = headers
194 .get(SESSION_HEADER)
195 .and_then(|value| value.to_str().ok())
196 .filter(|value| !value.trim().is_empty())
197 .map(str::to_string)
198 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
199
200 {
201 let sessions = state.sessions.read().await;
202 if let Some(entry) = sessions.get(&id) {
203 if entry.expires_at > now {
204 return Ok(entry.session.clone());
205 }
206 }
207 }
208
209 let session = Arc::new(
210 Session::new(Scope::Conversation(id.clone()))
211 .map_err(|source| ProxyError::Pipeline { source })?,
212 );
213 let mut sessions = state.sessions.write().await;
214 sessions.retain(|_, entry| entry.expires_at > now);
215 sessions.insert(
216 id,
217 SessionEntry {
218 session: session.clone(),
219 expires_at: now + state.config.session_ttl,
220 },
221 );
222 Ok(session)
223}
224
225fn redact_surfaces(
226 pipeline: &Pipeline,
227 session: &Session,
228 surfaces: Vec<crate::adapter::PiiSurface<'_>>,
229) -> Result<(), ProxyError> {
230 for surface in surfaces {
231 let clean = pipeline
232 .redact(session, RawDocument::Text(surface.text.clone()))
233 .map_err(|source| ProxyError::Pipeline { source })?;
234 if let CleanDocument::Text(text) = clean {
235 *surface.text = text;
236 }
237 }
238 Ok(())
239}
240
241fn restore_surfaces(session: &Session, surfaces: Vec<crate::adapter::PiiSurface<'_>>) {
242 for surface in surfaces {
243 *surface.text = restore_text(session, surface.text);
244 }
245}
246
247fn restore_text(session: &Session, clean: &str) -> String {
248 let mut restored = String::new();
249 let mut last = 0;
250 for matched in token_shape::pattern().find_iter(clean) {
251 restored.push_str(&clean[last..matched.start()]);
252 if let Some(raw) = session.restore(matched.as_str()) {
253 restored.push_str(&raw);
254 } else {
255 restored.push_str(matched.as_str());
256 }
257 last = matched.end();
258 }
259 restored.push_str(&clean[last..]);
260 restored
261}
262
263fn transform_sse(
264 adapter: &Arc<dyn ProviderAdapter>,
265 session: &Session,
266 bytes: &[u8],
267) -> Result<Vec<u8>, ProxyError> {
268 let text = std::str::from_utf8(bytes).map_err(|err| ProxyError::SsePartialFrame {
269 reason: err.to_string(),
270 })?;
271 let mut out = String::new();
272 for frame in text.split("\n\n") {
273 if frame.trim().is_empty() {
274 continue;
275 }
276 let mut event_name = None;
277 let mut data_lines = Vec::new();
278 for line in frame.lines() {
279 if let Some(rest) = line.strip_prefix("event:") {
280 event_name = Some(rest.trim().to_string());
281 } else if let Some(rest) = line.strip_prefix("data:") {
282 data_lines.push(rest.trim_start());
283 } else {
284 out.push_str(line);
285 out.push('\n');
286 }
287 }
288 let data = data_lines.join("\n");
289 if data == "[DONE]" {
290 if let Some(name) = event_name {
291 out.push_str("event: ");
292 out.push_str(&name);
293 out.push('\n');
294 }
295 out.push_str("data: [DONE]\n\n");
296 continue;
297 }
298 let mut event = SseEvent {
299 event: event_name.clone(),
300 data: serde_json::from_str(&data)
301 .map_err(|source| ProxyError::InvalidJson { source })?,
302 };
303 restore_surfaces(session, adapter.sse_event_pii_surfaces(&mut event));
304 if let Some(name) = event_name {
305 out.push_str("event: ");
306 out.push_str(&name);
307 out.push('\n');
308 }
309 out.push_str("data: ");
310 out.push_str(
311 &serde_json::to_string(&event.data)
312 .map_err(|source| ProxyError::InvalidJson { source })?,
313 );
314 out.push_str("\n\n");
315 }
316 Ok(out.into_bytes())
317}
318
319fn upstream_url(base: &Url, uri: &Uri) -> Result<Url, ProxyError> {
320 let path = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
321 base.join(path).map_err(|err| ProxyError::DaemonConfig {
322 detail: err.to_string(),
323 })
324}
325
326fn forward_headers(
327 mut request: reqwest::RequestBuilder,
328 headers: &HeaderMap,
329) -> reqwest::RequestBuilder {
330 const FORWARDED: &[&str] = &[
331 "authorization",
332 "x-api-key",
333 "anthropic-version",
334 "openai-beta",
335 "content-type",
336 ];
337 for name in FORWARDED {
338 let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
339 continue;
340 };
341 if let Some(value) = headers.get(&header_name) {
342 request = request.header(name.to_string(), value.clone());
343 }
344 }
345 request
346}
347
348fn proxy_error_response(err: ProxyError) -> Response {
349 let status = match err {
350 ProxyError::AdapterNotFound { .. } => StatusCode::NOT_FOUND,
351 ProxyError::BodyTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
352 ProxyError::InvalidJson { .. } => StatusCode::BAD_REQUEST,
353 ProxyError::UpstreamUnreachable { .. } => StatusCode::BAD_GATEWAY,
354 _ => StatusCode::INTERNAL_SERVER_ERROR,
355 };
356 let body = serde_json::json!({
357 "error": proxy_error_name(&err),
358 });
359 (
360 status,
361 [(
362 axum::http::header::CONTENT_TYPE,
363 HeaderValue::from_static("application/json"),
364 )],
365 body.to_string(),
366 )
367 .into_response()
368}
369
370fn proxy_error_name(err: &ProxyError) -> &'static str {
371 match err {
372 ProxyError::UpstreamUnreachable { .. } => "UpstreamUnreachable",
373 ProxyError::AdapterNotFound { .. } => "AdapterNotFound",
374 ProxyError::BodyTooLarge { .. } => "BodyTooLarge",
375 ProxyError::InvalidJson { .. } => "InvalidJson",
376 ProxyError::SsePartialFrame { .. } => "SsePartialFrame",
377 ProxyError::Pipeline { .. } => "Pipeline",
378 ProxyError::Server { .. } => "Server",
379 ProxyError::DaemonAlreadyRunning { .. } => "DaemonAlreadyRunning",
380 ProxyError::DaemonNotRunning => "DaemonNotRunning",
381 ProxyError::DaemonPidfileStale { .. } => "DaemonPidfileStale",
382 ProxyError::DaemonIo { .. } => "DaemonIo",
383 ProxyError::DaemonConfig { .. } => "DaemonConfig",
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn restore_text_leaves_unknown_tokens() {
393 let session = Session::new(Scope::Conversation("test".to_string())).unwrap();
394 assert_eq!(restore_text(&session, "hello <Email_1>"), "hello <Email_1>");
395 }
396
397 #[test]
398 fn upstream_url_preserves_path_and_query() {
399 let base = Url::parse("https://api.openai.com").unwrap();
400 let uri: Uri = "/v1/chat/completions?x=1".parse().unwrap();
401 assert_eq!(
402 upstream_url(&base, &uri).unwrap().as_str(),
403 "https://api.openai.com/v1/chat/completions?x=1"
404 );
405 }
406
407 #[test]
408 fn health_lists_adapters() {
409 let config = ProxyConfig::new(
410 "127.0.0.1:0".parse().unwrap(),
411 vec![Arc::new(crate::adapters::OpenAiAdapter::new(
412 Url::parse("https://api.openai.com").unwrap(),
413 ))],
414 );
415 let state = AppState {
416 config: Arc::new(config),
417 pipeline: Arc::new(Pipeline::builder().build().unwrap()),
418 client: Client::new(),
419 sessions: Arc::new(RwLock::new(HashMap::new())),
420 started_at: Instant::now(),
421 };
422 assert_eq!(health_snapshot(&state).adapters[0].name, "openai");
423 }
424}