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            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}