Skip to main content

switchyard_translation/
engine.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Registry-backed translation engine for buffered requests and responses.
5
6use std::collections::BTreeMap;
7use std::sync::Arc;
8
9use serde_json::Value;
10
11use crate::LlmResponseStreamEvent;
12use crate::codecs::FormatCodec;
13use crate::codecs::anthropic::AnthropicMessagesCodec;
14use crate::codecs::openai_chat::OpenAiChatCodec;
15use crate::codecs::responses::OpenAiResponsesCodec;
16use crate::codecs::stream::{
17    StreamCodecRegistry, StreamTranslationState, encode_response_stream_event,
18};
19use crate::diagnostic::TranslationDiagnostic;
20use crate::error::{Result, TranslationError};
21use crate::format::FormatId;
22use crate::llm::{AggLlmResponse, LlmRequest};
23use crate::policy::TranslationPolicy;
24
25/// Encoded translation result with any diagnostics emitted along the way.
26#[derive(Debug)]
27pub struct TranslationOutput {
28    pub body: Value,
29    pub diagnostics: Vec<TranslationDiagnostic>,
30}
31
32/// Decoded request IR plus diagnostics.
33#[derive(Debug)]
34pub struct RequestIrOutput {
35    pub request: LlmRequest,
36    pub diagnostics: Vec<TranslationDiagnostic>,
37}
38
39/// Decoded response IR plus diagnostics.
40#[derive(Debug)]
41pub struct ResponseIrOutput {
42    pub response: AggLlmResponse,
43    pub diagnostics: Vec<TranslationDiagnostic>,
44}
45
46/// Registry mapping wire formats to buffered codecs.
47#[derive(Default)]
48pub struct FormatRegistry {
49    codecs: BTreeMap<FormatId, Arc<dyn FormatCodec>>,
50}
51
52impl FormatRegistry {
53    /// Creates an empty format registry.
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Creates a registry populated with the built-in provider codecs.
59    pub fn with_builtins() -> Self {
60        let mut registry = Self::new();
61        registry.register(OpenAiChatCodec);
62        registry.register(AnthropicMessagesCodec);
63        registry.register(OpenAiResponsesCodec);
64        registry
65    }
66
67    /// Registers or replaces a codec for its declared format.
68    pub fn register(&mut self, codec: impl FormatCodec + 'static) {
69        self.codecs.insert(codec.format(), Arc::new(codec));
70    }
71
72    /// Looks up a codec by format identifier.
73    pub fn codec(&self, format: impl Into<FormatId>) -> Result<Arc<dyn FormatCodec>> {
74        let format = format.into();
75        self.codecs
76            .get(&format)
77            .cloned()
78            .ok_or_else(|| TranslationError::Other(format!("no codec registered for {format}")))
79    }
80}
81
82/// Stateless request/response translator that routes through the neutral IR.
83pub struct TranslationEngine {
84    registry: FormatRegistry,
85    stream_registry: StreamCodecRegistry,
86}
87
88impl Default for TranslationEngine {
89    fn default() -> Self {
90        Self {
91            registry: FormatRegistry::with_builtins(),
92            stream_registry: StreamCodecRegistry::with_builtins(),
93        }
94    }
95}
96
97impl TranslationEngine {
98    /// Creates an engine from an explicit buffered codec registry.
99    pub fn new(registry: FormatRegistry) -> Self {
100        Self {
101            registry,
102            stream_registry: StreamCodecRegistry::with_builtins(),
103        }
104    }
105
106    /// Creates an engine from explicit buffered and streaming codec registries.
107    pub fn with_registries(registry: FormatRegistry, stream_registry: StreamCodecRegistry) -> Self {
108        Self {
109            registry,
110            stream_registry,
111        }
112    }
113
114    /// Decodes a request body into the neutral request IR.
115    pub fn decode_request(
116        &self,
117        source: impl Into<FormatId>,
118        body: &Value,
119        policy: &TranslationPolicy,
120    ) -> Result<RequestIrOutput> {
121        let source = source.into();
122        let decoded = self.registry.codec(source)?.decode_request(body, policy)?;
123        Ok(RequestIrOutput {
124            request: decoded.request,
125            diagnostics: decoded.diagnostics,
126        })
127    }
128
129    /// Encodes a neutral request IR into a target wire format.
130    pub fn encode_request(
131        &self,
132        target: impl Into<FormatId>,
133        request: &LlmRequest,
134        policy: &TranslationPolicy,
135    ) -> Result<TranslationOutput> {
136        let target = target.into();
137        let encoded = self
138            .registry
139            .codec(target)?
140            .encode_request(request, policy)?;
141        Ok(TranslationOutput {
142            body: encoded.body,
143            diagnostics: encoded.diagnostics,
144        })
145    }
146
147    /// Translates a request body from source format to target format.
148    pub fn translate_request(
149        &self,
150        source: impl Into<FormatId>,
151        target: impl Into<FormatId>,
152        body: &Value,
153        policy: &TranslationPolicy,
154    ) -> Result<TranslationOutput> {
155        let source = source.into();
156        let target = target.into();
157        let decoded = self
158            .registry
159            .codec(source.clone())?
160            .decode_request(body, policy)?;
161        let encoded = self
162            .registry
163            .codec(target.clone())?
164            .encode_request(&decoded.request, policy)?;
165        Ok(TranslationOutput {
166            body: encoded.body,
167            diagnostics: with_formats(decoded.diagnostics, encoded.diagnostics, source, target),
168        })
169    }
170
171    /// Decodes a response body into the neutral response IR.
172    pub fn decode_response(
173        &self,
174        source: impl Into<FormatId>,
175        body: &Value,
176        policy: &TranslationPolicy,
177    ) -> Result<ResponseIrOutput> {
178        let source = source.into();
179        let decoded = self.registry.codec(source)?.decode_response(body, policy)?;
180        Ok(ResponseIrOutput {
181            response: decoded.response,
182            diagnostics: decoded.diagnostics,
183        })
184    }
185
186    /// Encodes a neutral response IR into a target wire format.
187    pub fn encode_response(
188        &self,
189        target: impl Into<FormatId>,
190        response: &AggLlmResponse,
191        policy: &TranslationPolicy,
192    ) -> Result<TranslationOutput> {
193        let target = target.into();
194        let encoded = self
195            .registry
196            .codec(target)?
197            .encode_response(response, policy)?;
198        Ok(TranslationOutput {
199            body: encoded.body,
200            diagnostics: encoded.diagnostics,
201        })
202    }
203
204    /// Translates a response body from source format to target format.
205    pub fn translate_response(
206        &self,
207        source: impl Into<FormatId>,
208        target: impl Into<FormatId>,
209        body: &Value,
210        policy: &TranslationPolicy,
211    ) -> Result<TranslationOutput> {
212        let source = source.into();
213        let target = target.into();
214        let decoded = self
215            .registry
216            .codec(source.clone())?
217            .decode_response(body, policy)?;
218        let encoded = self
219            .registry
220            .codec(target.clone())?
221            .encode_response(&decoded.response, policy)?;
222        Ok(TranslationOutput {
223            body: encoded.body,
224            diagnostics: with_formats(decoded.diagnostics, encoded.diagnostics, source, target),
225        })
226    }
227
228    /// Translates one streaming source event into zero or more target events.
229    pub fn translate_event(
230        &self,
231        state: &mut StreamTranslationState,
232        source: impl Into<FormatId>,
233        target: impl Into<FormatId>,
234        event: &Value,
235    ) -> Result<Vec<Value>> {
236        let source = source.into();
237        let target = target.into();
238        let source_codec = self.stream_registry.codec(source.clone())?;
239        let target_codec = self.stream_registry.codec(target.clone())?;
240        let canonical = source_codec.decode_event(state, event);
241        state.source = Some(source);
242        state.target = Some(target);
243        Ok(canonical
244            .into_iter()
245            .flat_map(|event| target_codec.encode_event(state, event))
246            .collect())
247    }
248
249    /// Decodes one provider event while retaining its parsed source JSON value.
250    ///
251    /// Takes ownership of `event` so preservation does not deep-copy provider JSON
252    /// on the per-event streaming path.
253    pub fn decode_stream_event(
254        &self,
255        state: &mut StreamTranslationState,
256        source: impl Into<FormatId>,
257        event: Value,
258    ) -> Result<LlmResponseStreamEvent> {
259        let source = source.into();
260        let source_codec = self.stream_registry.codec(source.clone())?;
261        state.source = Some(source.clone());
262        let normalized = source_codec.decode_event(state, &event);
263        Ok(LlmResponseStreamEvent::preserved(source, event, normalized))
264    }
265
266    /// Encodes one neutral or preserved stream event for a target provider.
267    ///
268    /// A preserved event replays its retained JSON value unchanged when its
269    /// source and target formats match. Cross-format encoding intentionally
270    /// uses only its normalized events.
271    pub fn encode_stream_event(
272        &self,
273        state: &mut StreamTranslationState,
274        target: impl Into<FormatId>,
275        event: LlmResponseStreamEvent,
276    ) -> Result<Vec<Value>> {
277        let target = target.into();
278        let target_codec = self.stream_registry.codec(target.clone())?;
279        state.target = Some(target.clone());
280        Ok(encode_response_stream_event(
281            state,
282            target_codec.as_ref(),
283            &target,
284            event,
285        ))
286    }
287
288    /// Finishes target-provider stream emission after the source stream closes.
289    pub fn finish_stream(
290        &self,
291        state: &mut StreamTranslationState,
292        target: impl Into<FormatId>,
293    ) -> Result<Vec<Value>> {
294        let target = target.into();
295        let target_codec = self.stream_registry.codec(target)?;
296        Ok(target_codec.finish(state))
297    }
298}
299
300// Attaches source and target formats to every diagnostic emitted across both passes.
301fn with_formats(
302    decoded: Vec<TranslationDiagnostic>,
303    encoded: Vec<TranslationDiagnostic>,
304    source: FormatId,
305    target: FormatId,
306) -> Vec<TranslationDiagnostic> {
307    decoded
308        .into_iter()
309        .chain(encoded)
310        .map(|diagnostic| diagnostic.with_formats(source.clone(), target.clone()))
311        .collect()
312}