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#[derive(Clone, Copy)]
12pub enum ProtocolContract<'a> {
13 LegacySurfaces,
15 Codec(&'a dyn BodyCodec),
17}
18
19impl ProtocolContract<'_> {
20 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SessionPolicy {
48 EphemeralSingleRequest,
50 OpaqueHeaderContinuity(SessionRegistryConfig),
52}
53
54#[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#[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
247pub enum RoutePolicy {
248 LegacyAdapterMatch,
249 ExactMethodAndPath,
250}
251
252#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254pub enum HeaderPolicy {
255 LegacyForwarded,
256 StrictAllowlist,
257}
258
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub enum RedirectPolicy {
262 LegacyClient,
263 Deny,
264}
265
266#[derive(Clone, Copy, Debug, Eq, PartialEq)]
268pub enum CoveragePolicy {
269 LegacySurfaces,
270 CodecProved,
271}
272
273#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub struct FormatPolicy {
276 request: WireFormat,
277 response: WireFormat,
278 streaming_response: WireFormat,
279}
280
281impl FormatPolicy {
282 #[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#[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 #[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 #[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 #[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]
464pub trait ProviderAdapter: Send + Sync + 'static {
465 fn contract(&self) -> AdapterContract<'_>;
468
469 fn name(&self) -> &'static str;
470 fn matches_path(&self, method: &Method, path: &str) -> bool;
471 fn upstream_base(&self) -> &Url;
472 fn request_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>>;
473 fn response_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>>;
474 fn sse_event_pii_surfaces<'a>(&self, event: &'a mut SseEvent) -> Vec<PiiSurface<'a>>;
475
476 fn requests_json_output(&self, _request: &Value) -> bool {
480 false
481 }
482}
483
484#[derive(Clone, Copy, Debug, Eq, PartialEq)]
488pub enum SurfaceSyntax {
489 Text,
491 Json,
497 ModelOutput,
500}
501
502pub struct PiiSurface<'a> {
503 pub field_path: String,
504 pub text: &'a mut String,
505 pub syntax: SurfaceSyntax,
506}
507
508#[derive(Debug, Clone)]
509#[non_exhaustive]
510pub struct SseEvent {
511 pub event: Option<String>,
512 pub data: Value,
513}
514
515impl SseEvent {
516 pub fn new(event: Option<String>, data: Value) -> Self {
517 Self { event, data }
518 }
519}
520
521pub(crate) fn push_string<'a>(
522 surfaces: &mut Vec<PiiSurface<'a>>,
523 field_path: impl Into<String>,
524 value: &'a mut Value,
525 syntax: SurfaceSyntax,
526) {
527 if let Value::String(text) = value {
528 surfaces.push(PiiSurface {
529 field_path: field_path.into(),
530 text,
531 syntax,
532 });
533 }
534}
535
536pub(crate) fn walk_all_strings<'a>(
539 surfaces: &mut Vec<PiiSurface<'a>>,
540 prefix: String,
541 value: &'a mut Value,
542) {
543 match value {
544 Value::String(text) => surfaces.push(PiiSurface {
545 field_path: prefix,
546 text,
547 syntax: SurfaceSyntax::Text,
548 }),
549 Value::Array(items) => {
550 for (index, item) in items.iter_mut().enumerate() {
551 walk_all_strings(surfaces, format!("{prefix}[{index}]"), item);
552 }
553 }
554 Value::Object(map) => {
555 for (key, child) in map {
556 let path = if prefix.is_empty() {
557 key.clone()
558 } else {
559 format!("{prefix}.{key}")
560 };
561 walk_all_strings(surfaces, path, child);
562 }
563 }
564 Value::Null | Value::Bool(_) | Value::Number(_) => {}
565 }
566}
567
568pub(crate) fn push_text_blocks<'a>(
569 surfaces: &mut Vec<PiiSurface<'a>>,
570 prefix: &str,
571 value: &'a mut Value,
572 syntax: SurfaceSyntax,
573) {
574 match value {
575 Value::String(text) => surfaces.push(PiiSurface {
576 field_path: prefix.to_string(),
577 text,
578 syntax,
579 }),
580 Value::Array(items) => {
581 for (index, item) in items.iter_mut().enumerate() {
582 match item {
583 Value::String(text) => surfaces.push(PiiSurface {
584 field_path: format!("{prefix}[{index}]"),
585 text,
586 syntax,
587 }),
588 Value::Object(map) => {
589 if matches!(map.get("type"), Some(Value::String(kind))
590 if kind == "text" || kind == "input_text" || kind == "output_text")
591 {
592 if let Some(Value::String(text)) = map.get_mut("text") {
593 surfaces.push(PiiSurface {
594 field_path: format!("{prefix}[{index}].text"),
595 text,
596 syntax,
597 });
598 }
599 } else if matches!(map.get("type"), Some(Value::String(kind)) if kind == "message")
600 {
601 if let Some(content) = map.get_mut("content") {
602 push_text_blocks(
603 surfaces,
604 &format!("{prefix}[{index}].content"),
605 content,
606 syntax,
607 );
608 }
609 }
610 }
611 _ => {}
612 }
613 }
614 }
615 _ => {}
616 }
617}