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        crate::codex_namespaces::restore_qualified_tool_names(
234            &mut output.body,
235            &crate::codex_namespaces::qualified_tool_origins(request_extensions),
236        );
237        crate::codex_custom_tools::restore_custom_tool_calls(
238            &mut output.body,
239            &crate::codex_custom_tools::custom_tool_names(request_extensions),
240        );
241        Ok(output)
242    }
243
244    /// Translates a response body from source format to target format.
245    pub fn translate_response(
246        &self,
247        source: impl Into<FormatId>,
248        target: impl Into<FormatId>,
249        body: &Value,
250        policy: &TranslationPolicy,
251    ) -> Result<TranslationOutput> {
252        let source = source.into();
253        let target = target.into();
254        let decoded = self
255            .registry
256            .codec(source.clone())?
257            .decode_response(body, policy)?;
258        let encoded = self
259            .registry
260            .codec(target.clone())?
261            .encode_response(&decoded.response, policy)?;
262        Ok(TranslationOutput {
263            body: encoded.body,
264            diagnostics: with_formats(decoded.diagnostics, encoded.diagnostics, source, target),
265        })
266    }
267
268    /// Translates one streaming source event into zero or more target events.
269    pub fn translate_event(
270        &self,
271        state: &mut StreamTranslationState,
272        source: impl Into<FormatId>,
273        target: impl Into<FormatId>,
274        event: &Value,
275    ) -> Result<Vec<Value>> {
276        let source = source.into();
277        let target = target.into();
278        let source_codec = self.stream_registry.codec(source.clone())?;
279        let target_codec = self.stream_registry.codec(target.clone())?;
280        if let Err(error) =
281            crate::codecs::responses::validate_stream_output(&source, &target, event)
282        {
283            return Ok(target_codec.encode_event(
284                state,
285                crate::LlmResponseChunk::DecodeError {
286                    message: error.to_string(),
287                },
288            ));
289        }
290        let canonical = source_codec.decode_event(state, event);
291        state.source = Some(source);
292        state.target = Some(target);
293        Ok(canonical
294            .into_iter()
295            .flat_map(|event| target_codec.encode_event(state, event))
296            .collect())
297    }
298
299    /// Decodes one provider event while retaining its parsed source JSON value.
300    ///
301    /// Takes ownership of `event` so preservation does not deep-copy provider JSON
302    /// on the per-event streaming path.
303    pub fn decode_stream_event(
304        &self,
305        state: &mut StreamTranslationState,
306        source: impl Into<FormatId>,
307        event: Value,
308    ) -> Result<LlmResponseStreamEvent> {
309        let source = source.into();
310        let source_codec = self.stream_registry.codec(source.clone())?;
311        state.source = Some(source.clone());
312        let normalized = source_codec.decode_event(state, &event);
313        Ok(LlmResponseStreamEvent::preserved(source, event, normalized))
314    }
315
316    /// Encodes one neutral or preserved stream event for a target provider.
317    ///
318    /// A preserved event replays its retained JSON value unchanged when its
319    /// source and target formats match. Cross-format encoding intentionally
320    /// uses only its normalized events.
321    pub fn encode_stream_event(
322        &self,
323        state: &mut StreamTranslationState,
324        target: impl Into<FormatId>,
325        event: LlmResponseStreamEvent,
326    ) -> Result<Vec<Value>> {
327        let target = target.into();
328        let target_codec = self.stream_registry.codec(target.clone())?;
329        state.target = Some(target.clone());
330        Ok(encode_response_stream_event(
331            state,
332            target_codec.as_ref(),
333            &target,
334            event,
335        ))
336    }
337
338    /// Finishes target-provider stream emission after the source stream closes.
339    pub fn finish_stream(
340        &self,
341        state: &mut StreamTranslationState,
342        target: impl Into<FormatId>,
343    ) -> Result<Vec<Value>> {
344        let target = target.into();
345        let target_codec = self.stream_registry.codec(target)?;
346        Ok(target_codec.finish(state))
347    }
348
349    /// Encodes a stream of IR chunks into a stream of target-format wire events.
350    ///
351    /// `served_model` is exposed as the response model (via the stream state's
352    /// `target_model`); `None` falls back to the id observed on the source stream.
353    /// The target stream codec is resolved once and reused per chunk; terminal events
354    /// (`message_stop` / `response.completed`) come from `finish`.
355    pub fn encode_stream(
356        &self,
357        target: impl Into<FormatId>,
358        chunks: LlmResponseStream,
359        served_model: Option<String>,
360    ) -> std::result::Result<RawEventStream, LlmClientError> {
361        self.encode_stream_with_extensions(
362            target,
363            chunks,
364            served_model,
365            &ProviderExtensions::default(),
366        )
367    }
368
369    /// Encodes a response stream, honouring request extensions.
370    ///
371    /// Identical to [`Self::encode_stream`] except that Codex tool namespaces recorded on
372    /// the request are restored on each encoded event.
373    pub fn encode_stream_with_extensions(
374        &self,
375        target: impl Into<FormatId>,
376        chunks: LlmResponseStream,
377        served_model: Option<String>,
378        request_extensions: &ProviderExtensions,
379    ) -> std::result::Result<RawEventStream, LlmClientError> {
380        let origins = crate::codex_namespaces::qualified_tool_origins(request_extensions);
381        let custom_tools = crate::codex_custom_tools::custom_tool_names(request_extensions);
382        let mut custom_state = crate::codex_custom_tools::CustomToolCallStreamState::default();
383        let target_format: FormatId = target.into();
384        let codec = self
385            .stream_registry
386            .codec(target_format.clone())
387            // Currently the only error is that the codec is missing, which is Configuration
388            .map_err(|err| LlmClientError::Configuration {
389                message: err.to_string(),
390            })?;
391
392        let served_model_for_events = served_model.clone();
393        let mut state = StreamTranslationState {
394            target: Some(target_format.clone()),
395            target_model: served_model,
396            ..Default::default()
397        };
398        let mut chunks = chunks;
399
400        let events = try_stream! {
401            while let Some(item) = chunks.next().await {
402                let event = item.map_err(LlmStreamError::Client)?;
403                let mut encoded =
404                    encode_response_stream_event(&mut state, codec.as_ref(), &target_format, event);
405                for value in &mut encoded {
406                    if let Some(model) = served_model_for_events.as_deref() {
407                        codec.stamp_streamed_response_model(value, model);
408                    }
409                    crate::codex_namespaces::restore_qualified_tool_names(value, &origins);
410                }
411                // Argument deltas for a freeform tool cannot be expressed on the wire; the
412                // rewritten completed item carries the input instead.
413                let mut encoded: Vec<Value> = encoded
414                    .into_iter()
415                    .filter_map(|mut value| {
416                        crate::codex_custom_tools::restore_custom_tool_calls_in_event(
417                            &mut value,
418                            &custom_tools,
419                            &mut custom_state,
420                        )
421                        .then_some(value)
422                    })
423                    .collect();
424                let terminal = encoded.pop();
425                for value in encoded {
426                    yield value;
427                }
428                match (state.errored, terminal) {
429                    (false, Some(value)) => yield value,
430                    (true, Some(value)) => Err(LlmStreamError::Upstream(value))?,
431                    (true, None) => Err(LlmStreamError::Client(LlmClientError::ResponseTranslation(
432                        "stream ended after an error without a terminal event".to_string(),
433                    )))?,
434                    _ => ()
435                }
436            }
437            for mut value in codec.finish(&mut state) {
438                if let Some(model) = served_model_for_events.as_deref() {
439                    codec.stamp_streamed_response_model(&mut value, model);
440                }
441                crate::codex_namespaces::restore_qualified_tool_names(&mut value, &origins);
442                if crate::codex_custom_tools::restore_custom_tool_calls_in_event(
443                    &mut value,
444                    &custom_tools,
445                    &mut custom_state,
446                ) {
447                    yield value;
448                }
449            }
450        };
451
452        Ok(Box::pin(events))
453    }
454
455    /// Decodes provider SSE bytes into normalized stream events.
456    ///
457    /// Operates on raw bytes, not any HTTP client type: the caller adapts its
458    /// transport's body stream into `Stream<Item = Result<Vec<u8>, _>>`. Frames are
459    /// buffered across chunks (a partial frame waits for its boundary); the source
460    /// stream codec is resolved once and reused for every frame.
461    ///
462    /// The decoder tracks the source format's protocol-specific terminal event. If
463    /// EOF arrives without that required event, the stream yields a deferred
464    /// [`LlmClientError::ResponseTranslation`] error after any valid decoded events.
465    pub fn decode_stream<S>(
466        &self,
467        source: impl Into<FormatId>,
468        bytes: S,
469    ) -> std::result::Result<LlmResponseStream, LlmClientError>
470    where
471        S: Stream<Item = std::result::Result<Vec<u8>, LlmClientError>> + Send + 'static,
472    {
473        let marker = sse::done_marker();
474        let source_format: FormatId = source.into();
475        let codec = self
476            .stream_registry
477            .codec(source_format.clone())
478            .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
479        // Adapt the byte-chunk stream into an async line reader. The BufReader
480        // reassembles data split across network chunks (including multi-byte UTF-8),
481        // and `lines()` yields one SSE field line at a time. The stream is boxed to
482        // an `io::Error` item so `into_async_read`'s error bound resolves cleanly. The
483        // source error is boxed intact rather than stringified, so
484        // `llm_client_error_from_io` can recover its original variant on the way out.
485        let io_bytes: Pin<Box<dyn Stream<Item = std::io::Result<Vec<u8>>> + Send>> =
486            Box::pin(bytes.map(|item| item.map_err(std::io::Error::other)));
487        let lines = futures::io::BufReader::new(io_bytes.into_async_read()).lines();
488
489        let mut state = StreamTranslationState {
490            source: Some(source_format.clone()),
491            ..StreamTranslationState::default()
492        };
493        let mut frame = String::new();
494        let mut saw_terminal = false;
495        let mut saw_error = false;
496        let stream = Box::pin(try_stream! {
497            futures::pin_mut!(lines);
498            while let Some(line) = lines.next().await {
499                let line = line.map_err(llm_client_error_from_io)?;
500                // A blank line (allowing a bare CR for CRLF streams) ends the frame.
501                if line.trim_end().is_empty() {
502                    let parsed = sse::parse_json_sse_frame(&frame, marker)
503                        .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
504                    frame.clear();
505                    match parsed {
506                        sse::SseFrame::Empty => {}
507                        sse::SseFrame::Done => {
508                            saw_terminal |= codec.sse_done_is_terminal();
509                            break;
510                        }
511                        sse::SseFrame::Data(value) => {
512                            saw_terminal |= codec.is_terminal_event(&value);
513                            let normalized = codec.decode_event(&mut state, &value);
514                            saw_error |= normalized.iter().any(|chunk| matches!(
515                                chunk,
516                                LlmResponseChunk::DecodeError { .. }
517                                    | LlmResponseChunk::StreamError { .. }
518                            ));
519                            yield LlmResponseStreamEvent::preserved(
520                                source_format.clone(),
521                                value,
522                                normalized,
523                            );
524                        }
525                    }
526                } else {
527                    frame.push_str(&line);
528                    frame.push('\n');
529                }
530            }
531
532            // A non-standard upstream might omit the final blank line; parse a trailing
533            // complete frame instead of losing its last chunk.
534            #[allow(clippy::collapsible_if)]
535            if !frame.trim_end().is_empty() {
536                let parsed = sse::parse_json_sse_frame(&frame, marker)
537                    .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
538                match parsed {
539                    sse::SseFrame::Done => {
540                        saw_terminal |= codec.sse_done_is_terminal();
541                    }
542                    sse::SseFrame::Data(value) => {
543                        saw_terminal |= codec.is_terminal_event(&value);
544                        let normalized = codec.decode_event(&mut state, &value);
545                        saw_error |= normalized.iter().any(|chunk| matches!(
546                            chunk,
547                            LlmResponseChunk::DecodeError { .. }
548                                | LlmResponseChunk::StreamError { .. }
549                        ));
550                        yield LlmResponseStreamEvent::preserved(source_format.clone(), value, normalized);
551                    }
552                    sse::SseFrame::Empty => {}
553                }
554            }
555
556            if !saw_terminal && !saw_error {
557                Err(LlmClientError::ResponseTranslation(format!(
558                    "{source_format} stream ended before a terminal event"
559                )))?;
560            }
561        });
562        Ok(stream)
563    }
564}
565
566// Recover transport errors wrapped for `AsyncRead`; other reader failures are
567// invalid upstream responses.
568fn llm_client_error_from_io(error: std::io::Error) -> LlmClientError {
569    let kind = error.kind();
570    let message = error.to_string();
571    match error.into_inner() {
572        Some(source) => match source.downcast::<LlmClientError>() {
573            Ok(error) => *error,
574            Err(source) => LlmClientError::InvalidResponse { source },
575        },
576        None => LlmClientError::InvalidResponse {
577            source: Box::new(std::io::Error::new(kind, message)),
578        },
579    }
580}
581
582// Attaches source and target formats to every diagnostic emitted across both passes.
583fn with_formats(
584    decoded: Vec<TranslationDiagnostic>,
585    encoded: Vec<TranslationDiagnostic>,
586    source: FormatId,
587    target: FormatId,
588) -> Vec<TranslationDiagnostic> {
589    decoded
590        .into_iter()
591        .chain(encoded)
592        .map(|diagnostic| diagnostic.with_formats(source.clone(), target.clone()))
593        .collect()
594}