Skip to main content

gproxy_transform/transform/
context.rs

1use std::cell::RefCell;
2use std::sync::{Arc, Mutex};
3
4use crate::protocol::OperationKey;
5
6/// Classification of a non-fatal semantic transform diagnostic.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TransformDiagnosticKind {
9    /// The source field has no representation in the target protocol.
10    UnsupportedField,
11    /// The field can only be approximated or is intentionally dropped.
12    LossyField,
13}
14
15/// A structured, non-fatal semantic loss reported by a transform.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct TransformDiagnostic {
18    /// Diagnostic category suitable for programmatic policy decisions.
19    pub kind: TransformDiagnosticKind,
20    /// Provider-relative semantic field path affected by the conversion.
21    pub field: String,
22    /// Human-readable explanation of the loss or approximation.
23    pub reason: String,
24}
25
26impl TransformDiagnostic {
27    /// Report a source field with no target-protocol representation.
28    pub fn unsupported(field: impl Into<String>, reason: impl Into<String>) -> Self {
29        Self {
30            kind: TransformDiagnosticKind::UnsupportedField,
31            field: field.into(),
32            reason: reason.into(),
33        }
34    }
35
36    /// Report a source field that was approximated or intentionally dropped.
37    pub fn lossy(field: impl Into<String>, reason: impl Into<String>) -> Self {
38        Self {
39            kind: TransformDiagnosticKind::LossyField,
40            field: field.into(),
41            reason: reason.into(),
42        }
43    }
44}
45
46/// A transformed value together with all non-fatal semantic diagnostics
47/// produced while creating it.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct TransformOutput<T> {
50    /// Converted value.
51    pub value: T,
52    /// Non-fatal semantic diagnostics produced by this conversion call.
53    pub diagnostics: Vec<TransformDiagnostic>,
54}
55
56impl<T> TransformOutput<T> {
57    /// Pair a converted value with its diagnostics.
58    pub fn new(value: T, diagnostics: Vec<TransformDiagnostic>) -> Self {
59        Self { value, diagnostics }
60    }
61
62    /// Discard diagnostics and return only the converted value.
63    pub fn into_value(self) -> T {
64        self.value
65    }
66
67    /// Split the converted value from its diagnostics.
68    pub fn into_parts(self) -> (T, Vec<TransformDiagnostic>) {
69        (self.value, self.diagnostics)
70    }
71}
72
73type DiagnosticSink = Arc<Mutex<Vec<TransformDiagnostic>>>;
74
75thread_local! {
76    static DIAGNOSTIC_SCOPES: RefCell<Vec<DiagnosticSink>> = const { RefCell::new(Vec::new()) };
77}
78
79/// Per-call transform settings.
80///
81/// `path`/`query` carry the INBOUND request target (provider-relative, as the
82/// client sent it) for transforms that need more than the body — e.g. the
83/// list-models query conversion. They are filled on the request direction via
84/// [`with_request`](Self::with_request); response-direction contexts leave
85/// them empty.
86#[derive(Debug, Clone)]
87pub struct TransformContext {
88    pub source: OperationKey,
89    pub target: OperationKey,
90    pub path: String,
91    pub query: Option<String>,
92    diagnostics: DiagnosticSink,
93}
94
95impl PartialEq for TransformContext {
96    fn eq(&self, other: &Self) -> bool {
97        self.source == other.source
98            && self.target == other.target
99            && self.path == other.path
100            && self.query == other.query
101    }
102}
103
104impl Eq for TransformContext {}
105
106impl TransformContext {
107    pub fn new(source: OperationKey, target: OperationKey) -> Self {
108        Self {
109            source,
110            target,
111            path: String::new(),
112            query: None,
113            diagnostics: Arc::new(Mutex::new(Vec::new())),
114        }
115    }
116
117    /// Attach the inbound request target (request-direction contexts).
118    pub fn with_request(mut self, path: &str, query: Option<&str>) -> Self {
119        self.path = path.to_owned();
120        self.query = query.map(str::to_owned);
121        self
122    }
123
124    /// Return a snapshot of diagnostics reported through this context.
125    pub fn diagnostics(&self) -> Vec<TransformDiagnostic> {
126        self.diagnostics
127            .lock()
128            .expect("transform diagnostic mutex poisoned")
129            .clone()
130    }
131
132    /// Drain diagnostics reported through this context.
133    pub fn take_diagnostics(&self) -> Vec<TransformDiagnostic> {
134        std::mem::take(
135            &mut *self
136                .diagnostics
137                .lock()
138                .expect("transform diagnostic mutex poisoned"),
139        )
140    }
141
142    pub(crate) fn isolated(&self) -> Self {
143        let mut isolated = self.clone();
144        isolated.diagnostics = Arc::new(Mutex::new(Vec::new()));
145        isolated
146    }
147
148    pub(crate) fn scope<T>(&self, f: impl FnOnce() -> T) -> T {
149        struct ScopeGuard;
150        impl Drop for ScopeGuard {
151            fn drop(&mut self) {
152                DIAGNOSTIC_SCOPES.with(|scopes| {
153                    scopes.borrow_mut().pop();
154                });
155            }
156        }
157
158        DIAGNOSTIC_SCOPES.with(|scopes| scopes.borrow_mut().push(self.diagnostics.clone()));
159        let guard = ScopeGuard;
160        let output = f();
161        drop(guard);
162        output
163    }
164}
165
166pub(crate) fn report_diagnostic(diagnostic: TransformDiagnostic) {
167    let sink = DIAGNOSTIC_SCOPES.with(|scopes| scopes.borrow().last().cloned());
168    if let Some(sink) = sink {
169        sink.lock()
170            .expect("transform diagnostic mutex poisoned")
171            .push(diagnostic);
172    } else {
173        tracing::warn!(
174            kind = ?diagnostic.kind,
175            field = %diagnostic.field,
176            reason = %diagnostic.reason,
177            "semantic loss outside a diagnostic transform scope"
178        );
179    }
180}
181
182pub(crate) fn report_lossy(field: impl Into<String>, reason: impl Into<String>) {
183    report_diagnostic(TransformDiagnostic::lossy(field, reason));
184}
185
186pub(crate) fn report_unsupported(field: impl Into<String>, reason: impl Into<String>) {
187    report_diagnostic(TransformDiagnostic::unsupported(field, reason));
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::protocol::{ContentGenerationKind, Operation};
194
195    #[test]
196    fn diagnostic_scopes_are_isolated_and_structured() {
197        let key = OperationKey::content_generation(
198            Operation::GenerateContent,
199            ContentGenerationKind::OpenAiResponses,
200        );
201        let context = TransformContext::new(key, key);
202        let isolated = context.isolated();
203        isolated.scope(|| report_lossy("input.cache_control", "not representable"));
204
205        assert!(context.diagnostics().is_empty());
206        assert_eq!(
207            isolated.take_diagnostics(),
208            vec![TransformDiagnostic::lossy(
209                "input.cache_control",
210                "not representable"
211            )]
212        );
213        assert!(isolated.diagnostics().is_empty());
214    }
215}