Skip to main content

gaze_proxy/
server.rs

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}