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 and streaming translation.
5
6use std::collections::BTreeMap;
7use std::pin::Pin;
8use std::sync::Arc;
9
10use async_stream::try_stream;
11use futures::io::AsyncBufReadExt;
12use futures::{Stream, StreamExt, TryStreamExt};
13use serde_json::Value;
14use switchyard_protocol::LlmClientError;
15
16use crate::codecs::FormatCodec;
17use crate::codecs::anthropic::AnthropicMessagesCodec;
18use crate::codecs::openai_chat::OpenAiChatCodec;
19use crate::codecs::responses::OpenAiResponsesCodec;
20use crate::codecs::stream::{
21    StreamCodecRegistry, StreamTranslationState, encode_response_stream_event,
22};
23use crate::diagnostic::TranslationDiagnostic;
24use crate::error::{Result, TranslationError};
25use crate::format::FormatId;
26use crate::llm::{AggLlmResponse, LlmRequest, ProviderExtensions};
27use crate::policy::TranslationPolicy;
28use crate::sse;
29use crate::{LlmResponseChunk, LlmResponseStream, LlmResponseStreamEvent, LlmStreamError};
30
31/// A stream of wire-format event objects in one format — the unframed body of an
32/// SSE response. The serving layer frames each `Value` (e.g. as an SSE
33/// `data:`/`event:` block).
34pub type RawEventStream =
35    Pin<Box<dyn Stream<Item = std::result::Result<Value, LlmStreamError>> + Send>>;
36
37/// Encoded translation result with any diagnostics emitted along the way.
38#[derive(Debug)]
39pub struct TranslationOutput {
40    pub body: Value,
41    pub diagnostics: Vec<TranslationDiagnostic>,
42}
43
44/// Decoded request IR plus diagnostics.
45#[derive(Debug)]
46pub struct RequestIrOutput {
47    pub request: LlmRequest,
48    pub diagnostics: Vec<TranslationDiagnostic>,
49}
50
51/// Decoded response IR plus diagnostics.
52#[derive(Debug)]
53pub struct ResponseIrOutput {
54    pub response: AggLlmResponse,
55    pub diagnostics: Vec<TranslationDiagnostic>,
56}
57
58/// Registry mapping wire formats to buffered codecs.
59#[derive(Default)]
60pub struct FormatRegistry {
61    codecs: BTreeMap<FormatId, Arc<dyn FormatCodec>>,
62}
63
64impl FormatRegistry {
65    /// Creates an empty format registry.
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Creates a registry populated with the built-in provider codecs.
71    pub fn with_builtins() -> Self {
72        let mut registry = Self::new();
73        registry.register(OpenAiChatCodec);
74        registry.register(AnthropicMessagesCodec);
75        registry.register(OpenAiResponsesCodec);
76        registry
77    }
78
79    /// Registers or replaces a codec for its declared format.
80    pub fn register(&mut self, codec: impl FormatCodec + 'static) {
81        self.codecs.insert(codec.format(), Arc::new(codec));
82    }
83
84    /// Looks up a codec by format identifier.
85    pub fn codec(&self, format: impl Into<FormatId>) -> Result<Arc<dyn FormatCodec>> {
86        let format = format.into();
87        self.codecs
88            .get(&format)
89            .cloned()
90            .ok_or_else(|| TranslationError::Other(format!("no codec registered for {format}")))
91    }
92}
93
94/// Stateless request/response translator that routes through the neutral IR.
95pub struct TranslationEngine {
96    registry: FormatRegistry,
97    stream_registry: StreamCodecRegistry,
98}
99
100impl Default for TranslationEngine {
101    fn default() -> Self {
102        Self {
103            registry: FormatRegistry::with_builtins(),
104            stream_registry: StreamCodecRegistry::with_builtins(),
105        }
106    }
107}
108
109impl TranslationEngine {
110    /// Creates an engine from an explicit buffered codec registry.
111    pub fn new(registry: FormatRegistry) -> Self {
112        Self {
113            registry,
114            stream_registry: StreamCodecRegistry::with_builtins(),
115        }
116    }
117
118    /// Creates an engine from explicit buffered and streaming codec registries.
119    pub fn with_registries(registry: FormatRegistry, stream_registry: StreamCodecRegistry) -> Self {
120        Self {
121            registry,
122            stream_registry,
123        }
124    }
125
126    /// Decodes a request body into the neutral request IR.
127    pub fn decode_request(
128        &self,
129        source: impl Into<FormatId>,
130        body: &Value,
131        policy: &TranslationPolicy,
132    ) -> Result<RequestIrOutput> {
133        let source = source.into();
134        let mut decoded = self.registry.codec(source)?.decode_request(body, policy)?;
135        crate::normalize_compaction(&mut decoded.request, None);
136        Ok(RequestIrOutput {
137            request: decoded.request,
138            diagnostics: decoded.diagnostics,
139        })
140    }
141
142    /// Encodes a neutral request IR into a target wire format.
143    ///
144    /// Preserved target bodies take precedence over normalized fields, including
145    /// `output.is_schema_enforced`. Clear the target's preservation entry or use
146    /// [`crate::policy::PreservationPolicy::Disabled`] to encode IR edits.
147    pub fn encode_request(
148        &self,
149        target: impl Into<FormatId>,
150        request: &LlmRequest,
151        policy: &TranslationPolicy,
152    ) -> Result<TranslationOutput> {
153        let target = target.into();
154        let encoded = self
155            .registry
156            .codec(target)?
157            .encode_request(request, policy)?;
158        Ok(TranslationOutput {
159            body: encoded.body,
160            diagnostics: encoded.diagnostics,
161        })
162    }
163
164    /// Translates a request body from source format to target format.
165    pub fn translate_request(
166        &self,
167        source: impl Into<FormatId>,
168        target: impl Into<FormatId>,
169        body: &Value,
170        policy: &TranslationPolicy,
171    ) -> Result<TranslationOutput> {
172        let source = source.into();
173        let target = target.into();
174        let decoded = self
175            .registry
176            .codec(source.clone())?
177            .decode_request(body, policy)?;
178        let encoded = self
179            .registry
180            .codec(target.clone())?
181            .encode_request(&decoded.request, policy)?;
182        Ok(TranslationOutput {
183            body: encoded.body,
184            diagnostics: with_formats(decoded.diagnostics, encoded.diagnostics, source, target),
185        })
186    }
187
188    /// Decodes a response body into the neutral response IR.
189    pub fn decode_response(
190        &self,
191        source: impl Into<FormatId>,
192        body: &Value,
193        policy: &TranslationPolicy,
194    ) -> Result<ResponseIrOutput> {
195        let source = source.into();
196        let decoded = self.registry.codec(source)?.decode_response(body, policy)?;
197        Ok(ResponseIrOutput {
198            response: decoded.response,
199            diagnostics: decoded.diagnostics,
200        })
201    }
202
203    /// Encodes a neutral response IR into a target wire format.
204    pub fn encode_response(
205        &self,
206        target: impl Into<FormatId>,
207        response: &AggLlmResponse,
208        policy: &TranslationPolicy,
209    ) -> Result<TranslationOutput> {
210        let target = target.into();
211        let encoded = self
212            .registry
213            .codec(target)?
214            .encode_response(response, policy)?;
215        Ok(TranslationOutput {
216            body: encoded.body,
217            diagnostics: encoded.diagnostics,
218        })
219    }
220
221    /// Encodes a neutral response IR while applying metadata retained from its request.
222    ///
223    /// Request extensions are used to restore caller-specific response details, such as
224    /// Codex tool namespaces, without exposing those details to the upstream provider.
225    pub fn encode_response_with_extensions(
226        &self,
227        target: impl Into<FormatId>,
228        response: &AggLlmResponse,
229        request_extensions: &ProviderExtensions,
230        policy: &TranslationPolicy,
231    ) -> Result<TranslationOutput> {
232        let mut output = self.encode_response(target, response, policy)?;
233        if !request_extensions.fields.is_empty() {
234            crate::codex_namespaces::restore_qualified_tool_names(
235                &mut output.body,
236                &crate::codex_namespaces::qualified_tool_origins(request_extensions),
237            );
238            crate::codex_custom_tools::restore_custom_tool_calls(
239                &mut output.body,
240                &crate::codex_custom_tools::custom_tool_names(request_extensions),
241            );
242        }
243        Ok(output)
244    }
245
246    /// Translates a response body from source format to target format.
247    pub fn translate_response(
248        &self,
249        source: impl Into<FormatId>,
250        target: impl Into<FormatId>,
251        body: &Value,
252        policy: &TranslationPolicy,
253    ) -> Result<TranslationOutput> {
254        self.translate_response_with_extensions(
255            source,
256            target,
257            body,
258            &ProviderExtensions::default(),
259            policy,
260        )
261    }
262
263    /// Translates a response while restoring details from the original request's extensions.
264    ///
265    /// Request extensions apply after encoding, including when the target body is preserved.
266    pub fn translate_response_with_extensions(
267        &self,
268        source: impl Into<FormatId>,
269        target: impl Into<FormatId>,
270        body: &Value,
271        request_extensions: &ProviderExtensions,
272        policy: &TranslationPolicy,
273    ) -> Result<TranslationOutput> {
274        let source = source.into();
275        let target = target.into();
276        let decoded = self
277            .registry
278            .codec(source.clone())?
279            .decode_response(body, policy)?;
280        let encoded = self.encode_response_with_extensions(
281            target.clone(),
282            &decoded.response,
283            request_extensions,
284            policy,
285        )?;
286        Ok(TranslationOutput {
287            body: encoded.body,
288            diagnostics: with_formats(decoded.diagnostics, encoded.diagnostics, source, target),
289        })
290    }
291
292    /// Translates one streaming source event into zero or more target events.
293    pub fn translate_event(
294        &self,
295        state: &mut StreamTranslationState,
296        source: impl Into<FormatId>,
297        target: impl Into<FormatId>,
298        event: &Value,
299    ) -> Result<Vec<Value>> {
300        let source = source.into();
301        let target = target.into();
302        let source_codec = self.stream_registry.codec(source.clone())?;
303        let target_codec = self.stream_registry.codec(target.clone())?;
304        if let Err(error) =
305            crate::codecs::responses::validate_stream_output(&source, &target, event)
306        {
307            return Ok(target_codec.encode_event(
308                state,
309                crate::LlmResponseChunk::DecodeError {
310                    message: error.to_string(),
311                },
312            ));
313        }
314        let canonical = source_codec.decode_event(state, event);
315        state.source = Some(source);
316        state.target = Some(target);
317        Ok(canonical
318            .into_iter()
319            .flat_map(|event| target_codec.encode_event(state, event))
320            .collect())
321    }
322
323    /// Decodes one provider event while retaining its parsed source JSON value.
324    ///
325    /// Takes ownership of `event` so preservation does not deep-copy provider JSON
326    /// on the per-event streaming path.
327    pub fn decode_stream_event(
328        &self,
329        state: &mut StreamTranslationState,
330        source: impl Into<FormatId>,
331        event: Value,
332    ) -> Result<LlmResponseStreamEvent> {
333        let source = source.into();
334        let source_codec = self.stream_registry.codec(source.clone())?;
335        state.source = Some(source.clone());
336        let normalized = source_codec.decode_event(state, &event);
337        Ok(LlmResponseStreamEvent::preserved(source, event, normalized))
338    }
339
340    /// Encodes one neutral or preserved stream event for a target provider.
341    ///
342    /// A preserved event replays its retained JSON value unchanged when its
343    /// source and target formats match. Cross-format encoding intentionally
344    /// uses only its normalized events.
345    pub fn encode_stream_event(
346        &self,
347        state: &mut StreamTranslationState,
348        target: impl Into<FormatId>,
349        event: LlmResponseStreamEvent,
350    ) -> Result<Vec<Value>> {
351        let target = target.into();
352        let target_codec = self.stream_registry.codec(target.clone())?;
353        state.target = Some(target.clone());
354        Ok(encode_response_stream_event(
355            state,
356            target_codec.as_ref(),
357            &target,
358            event,
359        ))
360    }
361
362    /// Finishes target-provider stream emission after the source stream closes.
363    pub fn finish_stream(
364        &self,
365        state: &mut StreamTranslationState,
366        target: impl Into<FormatId>,
367    ) -> Result<Vec<Value>> {
368        let target = target.into();
369        let target_codec = self.stream_registry.codec(target)?;
370        Ok(target_codec.finish(state))
371    }
372
373    /// Encodes a stream of IR chunks into a stream of target-format wire events.
374    ///
375    /// `served_model` is exposed as the response model (via the stream state's
376    /// `target_model`); `None` falls back to the id observed on the source stream.
377    /// The target stream codec is resolved once and reused per chunk; terminal events
378    /// (`message_stop` / `response.completed`) come from `finish`.
379    pub fn encode_stream(
380        &self,
381        target: impl Into<FormatId>,
382        chunks: LlmResponseStream,
383        served_model: Option<String>,
384    ) -> std::result::Result<RawEventStream, LlmClientError> {
385        self.encode_stream_with_extensions(
386            target,
387            chunks,
388            served_model,
389            &ProviderExtensions::default(),
390        )
391    }
392
393    /// Encodes a response stream, honouring request extensions.
394    ///
395    /// Identical to [`Self::encode_stream`] except that Codex tool namespaces recorded on
396    /// the request are restored on each encoded event.
397    pub fn encode_stream_with_extensions(
398        &self,
399        target: impl Into<FormatId>,
400        chunks: LlmResponseStream,
401        served_model: Option<String>,
402        request_extensions: &ProviderExtensions,
403    ) -> std::result::Result<RawEventStream, LlmClientError> {
404        let origins = crate::codex_namespaces::qualified_tool_origins(request_extensions);
405        let custom_tools = crate::codex_custom_tools::custom_tool_names(request_extensions);
406        let mut custom_state = crate::codex_custom_tools::CustomToolCallStreamState::default();
407        let target_format: FormatId = target.into();
408        let codec = self
409            .stream_registry
410            .codec(target_format.clone())
411            // Currently the only error is that the codec is missing, which is Configuration
412            .map_err(|err| LlmClientError::Configuration {
413                message: err.to_string(),
414            })?;
415
416        let served_model_for_events = served_model.clone();
417        let mut state = StreamTranslationState {
418            target: Some(target_format.clone()),
419            target_model: served_model,
420            ..Default::default()
421        };
422        let mut chunks = chunks;
423
424        let events = try_stream! {
425            while let Some(item) = chunks.next().await {
426                let event = item.map_err(LlmStreamError::Client)?;
427                let mut encoded =
428                    encode_response_stream_event(&mut state, codec.as_ref(), &target_format, event);
429                for value in &mut encoded {
430                    if let Some(model) = served_model_for_events.as_deref() {
431                        codec.stamp_streamed_response_model(value, model);
432                    }
433                    crate::codex_namespaces::restore_qualified_tool_names(value, &origins);
434                }
435                // Argument deltas for a freeform tool cannot be expressed on the wire; the
436                // rewritten completed item carries the input instead.
437                let mut encoded: Vec<Value> = encoded
438                    .into_iter()
439                    .filter_map(|mut value| {
440                        crate::codex_custom_tools::restore_custom_tool_calls_in_event(
441                            &mut value,
442                            &custom_tools,
443                            &mut custom_state,
444                        )
445                        .then_some(value)
446                    })
447                    .collect();
448                let terminal = encoded.pop();
449                for value in encoded {
450                    yield value;
451                }
452                match (state.errored, terminal) {
453                    (false, Some(value)) => yield value,
454                    (true, Some(value)) => Err(LlmStreamError::Upstream(value))?,
455                    (true, None) => Err(LlmStreamError::Client(LlmClientError::ResponseTranslation(
456                        "stream ended after an error without a terminal event".to_string(),
457                    )))?,
458                    _ => ()
459                }
460            }
461            for mut value in codec.finish(&mut state) {
462                if let Some(model) = served_model_for_events.as_deref() {
463                    codec.stamp_streamed_response_model(&mut value, model);
464                }
465                crate::codex_namespaces::restore_qualified_tool_names(&mut value, &origins);
466                if crate::codex_custom_tools::restore_custom_tool_calls_in_event(
467                    &mut value,
468                    &custom_tools,
469                    &mut custom_state,
470                ) {
471                    yield value;
472                }
473            }
474        };
475
476        Ok(Box::pin(events))
477    }
478
479    /// Decodes provider SSE bytes into normalized stream events.
480    ///
481    /// Operates on raw bytes, not any HTTP client type: the caller adapts its
482    /// transport's body stream into `Stream<Item = Result<Vec<u8>, _>>`. Frames are
483    /// buffered across chunks (a partial frame waits for its boundary); the source
484    /// stream codec is resolved once and reused for every frame.
485    ///
486    /// The decoder tracks the source format's protocol-specific terminal event. If
487    /// EOF arrives without that required event, the stream yields a deferred
488    /// [`LlmClientError::ResponseTranslation`] error after any valid decoded events.
489    pub fn decode_stream<S>(
490        &self,
491        source: impl Into<FormatId>,
492        bytes: S,
493    ) -> std::result::Result<LlmResponseStream, LlmClientError>
494    where
495        S: Stream<Item = std::result::Result<Vec<u8>, LlmClientError>> + Send + 'static,
496    {
497        let marker = sse::done_marker();
498        let source_format: FormatId = source.into();
499        let codec = self
500            .stream_registry
501            .codec(source_format.clone())
502            .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
503        // Adapt the byte-chunk stream into an async line reader. The BufReader
504        // reassembles data split across network chunks (including multi-byte UTF-8),
505        // and `lines()` yields one SSE field line at a time. The stream is boxed to
506        // an `io::Error` item so `into_async_read`'s error bound resolves cleanly. The
507        // source error is boxed intact rather than stringified, so
508        // `llm_client_error_from_io` can recover its original variant on the way out.
509        let io_bytes: Pin<Box<dyn Stream<Item = std::io::Result<Vec<u8>>> + Send>> =
510            Box::pin(bytes.map(|item| item.map_err(std::io::Error::other)));
511        let lines = futures::io::BufReader::new(io_bytes.into_async_read()).lines();
512
513        let mut state = StreamTranslationState {
514            source: Some(source_format.clone()),
515            ..StreamTranslationState::default()
516        };
517        let mut frame = String::new();
518        let mut saw_terminal = false;
519        let mut saw_error = false;
520        let stream = Box::pin(try_stream! {
521            futures::pin_mut!(lines);
522            while let Some(line) = lines.next().await {
523                let line = line.map_err(llm_client_error_from_io)?;
524                // A blank line (allowing a bare CR for CRLF streams) ends the frame.
525                if line.trim_end().is_empty() {
526                    let parsed = sse::parse_json_sse_frame(&frame, marker)
527                        .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
528                    frame.clear();
529                    match parsed {
530                        sse::SseFrame::Empty => {}
531                        sse::SseFrame::Done => {
532                            saw_terminal |= codec.sse_done_is_terminal();
533                            break;
534                        }
535                        sse::SseFrame::Data(value) => {
536                            saw_terminal |= codec.is_terminal_event(&value);
537                            let normalized = codec.decode_event(&mut state, &value);
538                            saw_error |= normalized.iter().any(|chunk| matches!(
539                                chunk,
540                                LlmResponseChunk::DecodeError { .. }
541                                    | LlmResponseChunk::StreamError { .. }
542                            ));
543                            yield LlmResponseStreamEvent::preserved(
544                                source_format.clone(),
545                                value,
546                                normalized,
547                            );
548                        }
549                    }
550                } else {
551                    frame.push_str(&line);
552                    frame.push('\n');
553                }
554            }
555
556            // A non-standard upstream might omit the final blank line; parse a trailing
557            // complete frame instead of losing its last chunk.
558            #[allow(clippy::collapsible_if)]
559            if !frame.trim_end().is_empty() {
560                let parsed = sse::parse_json_sse_frame(&frame, marker)
561                    .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
562                match parsed {
563                    sse::SseFrame::Done => {
564                        saw_terminal |= codec.sse_done_is_terminal();
565                    }
566                    sse::SseFrame::Data(value) => {
567                        saw_terminal |= codec.is_terminal_event(&value);
568                        let normalized = codec.decode_event(&mut state, &value);
569                        saw_error |= normalized.iter().any(|chunk| matches!(
570                            chunk,
571                            LlmResponseChunk::DecodeError { .. }
572                                | LlmResponseChunk::StreamError { .. }
573                        ));
574                        yield LlmResponseStreamEvent::preserved(source_format.clone(), value, normalized);
575                    }
576                    sse::SseFrame::Empty => {}
577                }
578            }
579
580            if !saw_terminal && !saw_error {
581                Err(LlmClientError::ResponseTranslation(format!(
582                    "{source_format} stream ended before a terminal event"
583                )))?;
584            }
585        });
586        Ok(stream)
587    }
588}
589
590// Recover transport errors wrapped for `AsyncRead`; other reader failures are
591// invalid upstream responses.
592fn llm_client_error_from_io(error: std::io::Error) -> LlmClientError {
593    let kind = error.kind();
594    let message = error.to_string();
595    match error.into_inner() {
596        Some(source) => match source.downcast::<LlmClientError>() {
597            Ok(error) => *error,
598            Err(source) => LlmClientError::InvalidResponse { source },
599        },
600        None => LlmClientError::InvalidResponse {
601            source: Box::new(std::io::Error::new(kind, message)),
602        },
603    }
604}
605
606// Attaches source and target formats to every diagnostic emitted across both passes.
607fn with_formats(
608    decoded: Vec<TranslationDiagnostic>,
609    encoded: Vec<TranslationDiagnostic>,
610    source: FormatId,
611    target: FormatId,
612) -> Vec<TranslationDiagnostic> {
613    decoded
614        .into_iter()
615        .chain(encoded)
616        .map(|diagnostic| diagnostic.with_formats(source.clone(), target.clone()))
617        .collect()
618}