Skip to main content

gaze_proxy/
adapter.rs

1use std::fmt;
2use std::time::Duration;
3
4use http::Method;
5use serde_json::Value;
6use url::Url;
7
8use crate::codec::{BodyCodec, CodecLimits, WireFormat};
9
10/// Transport protocol selected by a reviewed adapter contract.
11#[derive(Clone, Copy)]
12pub enum ProtocolContract<'a> {
13    /// Preserve the pre-codec JSON/SSE surface traversal path.
14    LegacySurfaces,
15    /// Transform complete bodies through the borrowed codec.
16    Codec(&'a dyn BodyCodec),
17}
18
19impl ProtocolContract<'_> {
20    /// Returns the selected codec, if this is a codec-backed contract.
21    #[must_use]
22    pub const fn codec(&self) -> Option<&dyn BodyCodec> {
23        match self {
24            Self::LegacySurfaces => None,
25            Self::Codec(codec) => Some(*codec),
26        }
27    }
28
29    /// Reports whether this contract preserves the legacy traversal path.
30    #[must_use]
31    pub const fn is_legacy(&self) -> bool {
32        matches!(self, Self::LegacySurfaces)
33    }
34}
35
36impl fmt::Debug for ProtocolContract<'_> {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::LegacySurfaces => formatter.write_str("LegacySurfaces"),
40            Self::Codec(_) => formatter.debug_tuple("Codec").field(&"BodyCodec").finish(),
41        }
42    }
43}
44
45/// Session lifecycle required by an adapter protocol.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SessionPolicy {
48    /// A request owns an isolated session that is discarded after its response.
49    EphemeralSingleRequest,
50    /// An opaque header selects a bounded continuity registry.
51    OpaqueHeaderContinuity(SessionRegistryConfig),
52}
53
54/// Closed validation failure for [`SessionRegistryConfig`].
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56pub enum SessionRegistryConfigError {
57    InvalidMaxActiveSessions,
58    InvalidSessionTtl,
59    InvalidMaxTombstones,
60    InvalidMaxTombstoneBytes,
61    InvalidTombstoneTtl,
62    NotALowering,
63}
64
65impl fmt::Display for SessionRegistryConfigError {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        formatter.write_str(match self {
68            Self::InvalidMaxActiveSessions => "invalid maximum active session count",
69            Self::InvalidSessionTtl => "invalid session TTL",
70            Self::InvalidMaxTombstones => "invalid maximum tombstone count",
71            Self::InvalidMaxTombstoneBytes => "invalid maximum tombstone byte count",
72            Self::InvalidTombstoneTtl => "invalid tombstone TTL",
73            Self::NotALowering => "registry limit may only be lowered",
74        })
75    }
76}
77
78impl std::error::Error for SessionRegistryConfigError {}
79
80/// Finite, validated bounds for an opaque-session continuity registry.
81///
82/// Fields are deliberately private so callers cannot bypass validation.
83///
84/// ```compile_fail
85/// use gaze_proxy::SessionRegistryConfig;
86///
87/// let _ = SessionRegistryConfig { max_active_sessions: 1 };
88/// ```
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct SessionRegistryConfig {
91    max_active_sessions: usize,
92    session_ttl: Duration,
93    max_tombstones: usize,
94    max_tombstone_bytes: usize,
95    tombstone_ttl: Duration,
96}
97
98impl Default for SessionRegistryConfig {
99    fn default() -> Self {
100        Self {
101            max_active_sessions: 4_096,
102            session_ttl: Duration::from_secs(30 * 60),
103            max_tombstones: 4_096,
104            max_tombstone_bytes: 1024 * 1024,
105            tombstone_ttl: Duration::from_secs(30 * 60),
106        }
107    }
108}
109
110impl SessionRegistryConfig {
111    pub const HARD_MAX_ACTIVE_SESSIONS: usize = 65_536;
112    pub const HARD_MAX_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
113    pub const HARD_MAX_TOMBSTONES: usize = 65_536;
114    pub const HARD_MAX_TOMBSTONE_BYTES: usize = 64 * 1024 * 1024;
115    pub const HARD_MAX_TOMBSTONE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
116
117    /// Constructs a registry configuration after validating every finite bound.
118    pub fn try_new(
119        max_active_sessions: usize,
120        session_ttl: Duration,
121        max_tombstones: usize,
122        max_tombstone_bytes: usize,
123        tombstone_ttl: Duration,
124    ) -> Result<Self, SessionRegistryConfigError> {
125        if max_active_sessions == 0 || max_active_sessions > Self::HARD_MAX_ACTIVE_SESSIONS {
126            return Err(SessionRegistryConfigError::InvalidMaxActiveSessions);
127        }
128        if session_ttl.is_zero() || session_ttl > Self::HARD_MAX_SESSION_TTL {
129            return Err(SessionRegistryConfigError::InvalidSessionTtl);
130        }
131        if max_tombstones == 0 || max_tombstones > Self::HARD_MAX_TOMBSTONES {
132            return Err(SessionRegistryConfigError::InvalidMaxTombstones);
133        }
134        if max_tombstone_bytes == 0 || max_tombstone_bytes > Self::HARD_MAX_TOMBSTONE_BYTES {
135            return Err(SessionRegistryConfigError::InvalidMaxTombstoneBytes);
136        }
137        if tombstone_ttl.is_zero() || tombstone_ttl > Self::HARD_MAX_TOMBSTONE_TTL {
138            return Err(SessionRegistryConfigError::InvalidTombstoneTtl);
139        }
140        Ok(Self {
141            max_active_sessions,
142            session_ttl,
143            max_tombstones,
144            max_tombstone_bytes,
145            tombstone_ttl,
146        })
147    }
148
149    #[must_use]
150    pub const fn max_active_sessions(&self) -> usize {
151        self.max_active_sessions
152    }
153
154    #[must_use]
155    pub const fn session_ttl(&self) -> Duration {
156        self.session_ttl
157    }
158
159    #[must_use]
160    pub const fn max_tombstones(&self) -> usize {
161        self.max_tombstones
162    }
163
164    #[must_use]
165    pub const fn max_tombstone_bytes(&self) -> usize {
166        self.max_tombstone_bytes
167    }
168
169    #[must_use]
170    pub const fn tombstone_ttl(&self) -> Duration {
171        self.tombstone_ttl
172    }
173
174    pub fn try_with_max_active_sessions(
175        mut self,
176        value: usize,
177    ) -> Result<Self, SessionRegistryConfigError> {
178        if value == 0 || value > Self::HARD_MAX_ACTIVE_SESSIONS {
179            return Err(SessionRegistryConfigError::InvalidMaxActiveSessions);
180        }
181        if value > self.max_active_sessions {
182            return Err(SessionRegistryConfigError::NotALowering);
183        }
184        self.max_active_sessions = value;
185        Ok(self)
186    }
187
188    pub fn try_with_session_ttl(
189        mut self,
190        value: Duration,
191    ) -> Result<Self, SessionRegistryConfigError> {
192        if value.is_zero() || value > Self::HARD_MAX_SESSION_TTL {
193            return Err(SessionRegistryConfigError::InvalidSessionTtl);
194        }
195        if value > self.session_ttl {
196            return Err(SessionRegistryConfigError::NotALowering);
197        }
198        self.session_ttl = value;
199        Ok(self)
200    }
201
202    pub fn try_with_max_tombstones(
203        mut self,
204        value: usize,
205    ) -> Result<Self, SessionRegistryConfigError> {
206        if value == 0 || value > Self::HARD_MAX_TOMBSTONES {
207            return Err(SessionRegistryConfigError::InvalidMaxTombstones);
208        }
209        if value > self.max_tombstones {
210            return Err(SessionRegistryConfigError::NotALowering);
211        }
212        self.max_tombstones = value;
213        Ok(self)
214    }
215
216    pub fn try_with_max_tombstone_bytes(
217        mut self,
218        value: usize,
219    ) -> Result<Self, SessionRegistryConfigError> {
220        if value == 0 || value > Self::HARD_MAX_TOMBSTONE_BYTES {
221            return Err(SessionRegistryConfigError::InvalidMaxTombstoneBytes);
222        }
223        if value > self.max_tombstone_bytes {
224            return Err(SessionRegistryConfigError::NotALowering);
225        }
226        self.max_tombstone_bytes = value;
227        Ok(self)
228    }
229
230    pub fn try_with_tombstone_ttl(
231        mut self,
232        value: Duration,
233    ) -> Result<Self, SessionRegistryConfigError> {
234        if value.is_zero() || value > Self::HARD_MAX_TOMBSTONE_TTL {
235            return Err(SessionRegistryConfigError::InvalidTombstoneTtl);
236        }
237        if value > self.tombstone_ttl {
238            return Err(SessionRegistryConfigError::NotALowering);
239        }
240        self.tombstone_ttl = value;
241        Ok(self)
242    }
243}
244
245/// Route matching policy declared by an adapter.
246#[derive(Clone, Copy, Debug, Eq, PartialEq)]
247pub enum RoutePolicy {
248    LegacyAdapterMatch,
249    ExactMethodAndPath,
250}
251
252/// Forwarded-header policy declared by an adapter.
253#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254pub enum HeaderPolicy {
255    LegacyForwarded,
256    StrictAllowlist,
257}
258
259/// Redirect policy declared by an adapter.
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub enum RedirectPolicy {
262    LegacyClient,
263    Deny,
264}
265
266/// PII coverage proof policy declared by an adapter.
267#[derive(Clone, Copy, Debug, Eq, PartialEq)]
268pub enum CoveragePolicy {
269    LegacySurfaces,
270    CodecProved,
271}
272
273/// Request, response, and streaming wire formats accepted by an adapter.
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub struct FormatPolicy {
276    request: WireFormat,
277    response: WireFormat,
278    streaming_response: WireFormat,
279}
280
281impl FormatPolicy {
282    /// Characterizes the pre-codec JSON request/response and SSE stream behavior.
283    #[must_use]
284    pub const fn legacy() -> Self {
285        Self {
286            request: WireFormat::Json,
287            response: WireFormat::Json,
288            streaming_response: WireFormat::Sse,
289        }
290    }
291
292    #[must_use]
293    pub const fn request(&self) -> WireFormat {
294        self.request
295    }
296
297    #[must_use]
298    pub const fn response(&self) -> WireFormat {
299        self.response
300    }
301
302    #[must_use]
303    pub const fn streaming_response(&self) -> WireFormat {
304        self.streaming_response
305    }
306}
307
308/// Reviewed protocol, session, routing, and coverage declarations for an adapter.
309///
310/// Fields are private so downstream code cannot assemble an unreviewed contract.
311/// Existing adapters obtain [`Self::legacy`] through the default trait method.
312///
313/// ```compile_fail
314/// use gaze_proxy::{AdapterContract, ProtocolContract};
315///
316/// let _ = AdapterContract { protocol: ProtocolContract::LegacySurfaces };
317/// ```
318#[derive(Clone, Copy, Debug)]
319pub struct AdapterContract<'a> {
320    protocol: ProtocolContract<'a>,
321    session_policy: Option<SessionPolicy>,
322    route_policy: RoutePolicy,
323    header_policy: HeaderPolicy,
324    redirect_policy: RedirectPolicy,
325    coverage_policy: CoveragePolicy,
326    formats: FormatPolicy,
327    codec_limits: Option<CodecLimits>,
328}
329
330impl AdapterContract<'static> {
331    /// Characterizes the existing adapter behavior without changing it.
332    #[must_use]
333    pub const fn legacy() -> Self {
334        Self {
335            protocol: ProtocolContract::LegacySurfaces,
336            session_policy: None,
337            route_policy: RoutePolicy::LegacyAdapterMatch,
338            header_policy: HeaderPolicy::LegacyForwarded,
339            redirect_policy: RedirectPolicy::LegacyClient,
340            coverage_policy: CoveragePolicy::LegacySurfaces,
341            formats: FormatPolicy::legacy(),
342            codec_limits: None,
343        }
344    }
345}
346
347impl<'a> AdapterContract<'a> {
348    /// Constructs the only non-legacy policy bundle accepted by later direct adapters.
349    #[allow(
350        dead_code,
351        reason = "consumed by the direct adapter in the next serialized checkpoint"
352    )]
353    pub(crate) const fn codec(
354        codec: &'a dyn BodyCodec,
355        session_policy: SessionPolicy,
356        codec_limits: CodecLimits,
357    ) -> Self {
358        Self {
359            protocol: ProtocolContract::Codec(codec),
360            session_policy: Some(session_policy),
361            route_policy: RoutePolicy::ExactMethodAndPath,
362            header_policy: HeaderPolicy::StrictAllowlist,
363            redirect_policy: RedirectPolicy::Deny,
364            coverage_policy: CoveragePolicy::CodecProved,
365            formats: FormatPolicy::legacy(),
366            codec_limits: Some(codec_limits),
367        }
368    }
369
370    #[must_use]
371    pub const fn protocol(&self) -> ProtocolContract<'a> {
372        self.protocol
373    }
374
375    /// Legacy adapters preserve the server's pre-contract auto-create behavior.
376    #[must_use]
377    pub const fn uses_legacy_session_auto_create(&self) -> bool {
378        self.session_policy.is_none()
379    }
380
381    #[must_use]
382    pub const fn session_policy(&self) -> Option<SessionPolicy> {
383        self.session_policy
384    }
385
386    #[must_use]
387    pub const fn route_policy(&self) -> RoutePolicy {
388        self.route_policy
389    }
390
391    #[must_use]
392    pub const fn header_policy(&self) -> HeaderPolicy {
393        self.header_policy
394    }
395
396    #[must_use]
397    pub const fn redirect_policy(&self) -> RedirectPolicy {
398        self.redirect_policy
399    }
400
401    #[must_use]
402    pub const fn coverage_policy(&self) -> CoveragePolicy {
403        self.coverage_policy
404    }
405
406    #[must_use]
407    pub const fn formats(&self) -> FormatPolicy {
408        self.formats
409    }
410
411    #[must_use]
412    pub const fn codec_limits(&self) -> Option<CodecLimits> {
413        self.codec_limits
414    }
415}
416
417#[async_trait::async_trait]
418pub trait ProviderAdapter: Send + Sync + 'static {
419    /// Declares protocol and safety behavior while preserving old implementations.
420    fn contract(&self) -> AdapterContract<'_> {
421        AdapterContract::legacy()
422    }
423
424    fn name(&self) -> &'static str;
425    fn matches_path(&self, method: &Method, path: &str) -> bool;
426    fn upstream_base(&self) -> &Url;
427    fn request_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>>;
428    fn response_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>>;
429    fn sse_event_pii_surfaces<'a>(&self, event: &'a mut SseEvent) -> Vec<PiiSurface<'a>>;
430
431    /// Reports whether `request` asked the model for JSON output (a JSON mode or a JSON
432    /// schema), which makes every [`SurfaceSyntax::ModelOutput`] response surface a JSON
433    /// document. The default, `false`, restores model output as free text.
434    fn requests_json_output(&self, _request: &Value) -> bool {
435        false
436    }
437}
438
439/// The syntax of a surface's text, which decides how restore writes a raw value into it.
440///
441/// Only restore reads it: request protection scans a surface's text as it stands.
442#[derive(Clone, Copy, Debug, Eq, PartialEq)]
443pub enum SurfaceSyntax {
444    /// Free text. Restore writes the raw value byte for byte.
445    Text,
446    /// A serialized JSON document, such as tool-call `arguments`. A token can only stand inside
447    /// one of its string literals, so restore JSON-escapes the raw value; a verbatim `"`, `\`,
448    /// or control character would break the document or silently change the parsed value. A
449    /// value that was itself captured inside a JSON string literal is already escaped and is
450    /// written as it is.
451    Json,
452    /// Model-authored response text: [`Self::Json`] when the request asked for JSON output
453    /// ([`ProviderAdapter::requests_json_output`]), [`Self::Text`] otherwise.
454    ModelOutput,
455}
456
457pub struct PiiSurface<'a> {
458    pub field_path: String,
459    pub text: &'a mut String,
460    pub syntax: SurfaceSyntax,
461}
462
463#[derive(Debug, Clone)]
464#[non_exhaustive]
465pub struct SseEvent {
466    pub event: Option<String>,
467    pub data: Value,
468}
469
470impl SseEvent {
471    pub fn new(event: Option<String>, data: Value) -> Self {
472        Self { event, data }
473    }
474}
475
476pub(crate) fn push_string<'a>(
477    surfaces: &mut Vec<PiiSurface<'a>>,
478    field_path: impl Into<String>,
479    value: &'a mut Value,
480    syntax: SurfaceSyntax,
481) {
482    if let Value::String(text) = value {
483        surfaces.push(PiiSurface {
484            field_path: field_path.into(),
485            text,
486            syntax,
487        });
488    }
489}
490
491/// Surfaces every string under `value`. Each one is a decoded JSON string, so its syntax is
492/// [`SurfaceSyntax::Text`]: re-serializing the body escapes it.
493pub(crate) fn walk_all_strings<'a>(
494    surfaces: &mut Vec<PiiSurface<'a>>,
495    prefix: String,
496    value: &'a mut Value,
497) {
498    match value {
499        Value::String(text) => surfaces.push(PiiSurface {
500            field_path: prefix,
501            text,
502            syntax: SurfaceSyntax::Text,
503        }),
504        Value::Array(items) => {
505            for (index, item) in items.iter_mut().enumerate() {
506                walk_all_strings(surfaces, format!("{prefix}[{index}]"), item);
507            }
508        }
509        Value::Object(map) => {
510            for (key, child) in map {
511                let path = if prefix.is_empty() {
512                    key.clone()
513                } else {
514                    format!("{prefix}.{key}")
515                };
516                walk_all_strings(surfaces, path, child);
517            }
518        }
519        Value::Null | Value::Bool(_) | Value::Number(_) => {}
520    }
521}
522
523pub(crate) fn push_text_blocks<'a>(
524    surfaces: &mut Vec<PiiSurface<'a>>,
525    prefix: &str,
526    value: &'a mut Value,
527    syntax: SurfaceSyntax,
528) {
529    match value {
530        Value::String(text) => surfaces.push(PiiSurface {
531            field_path: prefix.to_string(),
532            text,
533            syntax,
534        }),
535        Value::Array(items) => {
536            for (index, item) in items.iter_mut().enumerate() {
537                match item {
538                    Value::String(text) => surfaces.push(PiiSurface {
539                        field_path: format!("{prefix}[{index}]"),
540                        text,
541                        syntax,
542                    }),
543                    Value::Object(map) => {
544                        if matches!(map.get("type"), Some(Value::String(kind))
545                            if kind == "text" || kind == "input_text" || kind == "output_text")
546                        {
547                            if let Some(Value::String(text)) = map.get_mut("text") {
548                                surfaces.push(PiiSurface {
549                                    field_path: format!("{prefix}[{index}].text"),
550                                    text,
551                                    syntax,
552                                });
553                            }
554                        } else if matches!(map.get("type"), Some(Value::String(kind)) if kind == "message")
555                        {
556                            if let Some(content) = map.get_mut("content") {
557                                push_text_blocks(
558                                    surfaces,
559                                    &format!("{prefix}[{index}].content"),
560                                    content,
561                                    syntax,
562                                );
563                            }
564                        }
565                    }
566                    _ => {}
567                }
568            }
569        }
570        _ => {}
571    }
572}