switchyard_translation/codecs/
stream.rs1use std::collections::BTreeMap;
7use std::sync::Arc;
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value, json};
11
12use crate::LlmResponseChunk;
13use crate::codecs::anthropic::AnthropicMessagesStreamCodec;
14use crate::codecs::openai_chat::OpenAiChatStreamCodec;
15use crate::codecs::responses::OpenAiResponsesStreamCodec;
16use crate::engine::{FormatRegistry, TranslationEngine};
17use crate::error::{Result, TranslationError};
18use crate::format::{FormatId, WireFormat};
19use crate::llm::Usage;
20
21#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
23pub struct StreamTranslationState {
24 pub source: Option<FormatId>,
25 pub target: Option<FormatId>,
26 pub model: Option<String>,
28 pub message_id: Option<String>,
30 pub target_model: Option<String>,
33 pub target_message_id: Option<String>,
35 pub saw_message_start: bool,
36 pub emitted_message_start: bool,
37 pub finished: bool,
38 pub errored: bool,
40 pub usage: Usage,
41
42 pub(crate) output_tokens_seen: u64,
43 pub(crate) saw_backend_usage: bool,
44 pub(crate) stop_reason: Option<String>,
45 pub(crate) emitted_message_delta: bool,
46
47 pub(crate) next_content_index: usize,
48 pub(crate) text_block_index: Option<usize>,
49 pub(crate) text_block_started: bool,
50 pub(crate) emitted_content_block: bool,
51 pub(crate) tool_states: BTreeMap<usize, StreamToolState>,
52
53 pub(crate) response_created: bool,
54 pub(crate) response_text_started: bool,
55 pub(crate) response_text_output_index: Option<usize>,
56 pub(crate) response_text: String,
57 pub(crate) response_reasoning_started: bool,
58 pub(crate) response_reasoning_output_index: Option<usize>,
59 pub(crate) response_reasoning_text: String,
60 pub(crate) next_response_output_index: usize,
61
62 pub(crate) reasoning_block_index: Option<usize>,
63 pub(crate) reasoning_block_started: bool,
64}
65
66#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
68pub(crate) struct StreamToolState {
69 pub(crate) id: Option<String>,
70 pub(crate) name: Option<String>,
71 pub(crate) arguments: String,
72 pub(crate) pending_arguments: String,
73 pub(crate) started: bool,
74 pub(crate) content_index: Option<usize>,
75 pub(crate) response_output_index: Option<usize>,
76 pub(crate) response_item_id: Option<String>,
77}
78
79impl StreamTranslationState {
80 pub fn new(source: impl Into<FormatId>, target: impl Into<FormatId>) -> Self {
82 Self {
83 source: Some(source.into()),
84 target: Some(target.into()),
85 ..Self::default()
86 }
87 }
88}
89
90#[derive(Default)]
92pub struct StreamTranslationEngine {
93 engine: TranslationEngine,
94}
95
96pub trait StreamCodec: Send + Sync {
98 fn format(&self) -> FormatId;
100
101 fn decode_event(
103 &self,
104 state: &mut StreamTranslationState,
105 event: &Value,
106 ) -> Vec<LlmResponseChunk>;
107
108 fn encode_event(
110 &self,
111 state: &mut StreamTranslationState,
112 event: LlmResponseChunk,
113 ) -> Vec<Value>;
114
115 fn observe_replayed_event(
123 &self,
124 state: &mut StreamTranslationState,
125 _raw: &Value,
126 normalized: Vec<LlmResponseChunk>,
127 ) {
128 let replayed_terminal = normalized
129 .iter()
130 .any(|chunk| matches!(chunk, LlmResponseChunk::MessageStop { .. }));
131 for chunk in normalized {
132 drop(self.encode_event(state, chunk));
133 }
134 if replayed_terminal {
135 state.finished = true;
136 }
137 }
138
139 fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value>;
148}
149
150#[derive(Default)]
152pub struct StreamCodecRegistry {
153 codecs: BTreeMap<FormatId, Arc<dyn StreamCodec>>,
154}
155
156impl StreamCodecRegistry {
157 pub fn new() -> Self {
159 Self::default()
160 }
161
162 pub fn with_builtins() -> Self {
164 let mut registry = Self::new();
165 registry.register(OpenAiChatStreamCodec);
166 registry.register(AnthropicMessagesStreamCodec);
167 registry.register(OpenAiResponsesStreamCodec);
168 registry
169 }
170
171 pub fn register(&mut self, codec: impl StreamCodec + 'static) {
173 self.codecs.insert(codec.format(), Arc::new(codec));
174 }
175
176 pub fn codec(&self, format: impl Into<FormatId>) -> Result<Arc<dyn StreamCodec>> {
178 let format = format.into();
179 self.codecs.get(&format).cloned().ok_or_else(|| {
180 TranslationError::Other(format!("no stream codec registered for {format}"))
181 })
182 }
183}
184
185impl StreamTranslationEngine {
186 pub fn new(registry: StreamCodecRegistry) -> Self {
188 Self {
189 engine: TranslationEngine::with_registries(FormatRegistry::with_builtins(), registry),
190 }
191 }
192
193 pub fn translate_event(
195 &self,
196 state: &mut StreamTranslationState,
197 source: impl Into<FormatId>,
198 target: impl Into<FormatId>,
199 event: &Value,
200 ) -> Result<Vec<Value>> {
201 self.engine.translate_event(state, source, target, event)
202 }
203
204 pub fn finish(
206 &self,
207 state: &mut StreamTranslationState,
208 target: impl Into<FormatId>,
209 ) -> Result<Vec<Value>> {
210 self.engine.finish_stream(state, target)
211 }
212
213 pub fn translate_event_with_builtins(
215 state: &mut StreamTranslationState,
216 source: impl Into<FormatId>,
217 target: impl Into<FormatId>,
218 event: &Value,
219 ) -> Vec<Value> {
220 Self::default()
221 .translate_event(state, source, target, event)
222 .unwrap_or_else(|error| vec![json!({"error": {"message": error.to_string()}})])
223 }
224}
225
226pub fn decode_stream_event(
228 state: &mut StreamTranslationState,
229 source: impl Into<FormatId>,
230 event: &Value,
231) -> Vec<LlmResponseChunk> {
232 let source = source.into();
233 StreamCodecRegistry::with_builtins()
234 .codec(source)
235 .map(|codec| codec.decode_event(state, event))
236 .unwrap_or_else(|error| {
237 vec![LlmResponseChunk::DecodeError {
240 message: error.to_string(),
241 }]
242 })
243}
244
245pub(crate) fn encode_response_stream_event(
246 state: &mut StreamTranslationState,
247 target_codec: &dyn StreamCodec,
248 target: &FormatId,
249 event: crate::LlmResponseStreamEvent,
250) -> Vec<Value> {
251 if state.errored {
252 return Vec::new();
253 }
254 let (preservation, normalized) = event.into_parts();
255 if let Some(preservation) = preservation {
256 let (source, raw) = preservation.into_parts();
257 if &source == target {
258 target_codec.observe_replayed_event(state, &raw, normalized);
263 return vec![raw];
264 }
265 }
266
267 normalized
268 .into_iter()
269 .flat_map(|chunk| target_codec.encode_event(state, chunk))
270 .collect()
271}
272
273pub fn encode_stream_event(
275 state: &mut StreamTranslationState,
276 target: impl Into<FormatId>,
277 event: LlmResponseChunk,
278) -> Vec<Value> {
279 StreamCodecRegistry::with_builtins()
280 .codec(target)
281 .map(|codec| codec.encode_event(state, event))
282 .unwrap_or_else(|error| vec![json!({"error": {"message": error.to_string()}})])
283}
284
285pub(crate) fn record_source_identity(
287 state: &mut StreamTranslationState,
288 id: Option<String>,
289 model: Option<String>,
290) {
291 if id.is_some() {
292 state.message_id = id;
293 }
294 if model.is_some() {
295 state.model = model;
296 }
297}
298
299pub(crate) fn target_model_or_source_model(state: &StreamTranslationState) -> String {
301 state
302 .target_model
303 .clone()
304 .or_else(|| state.model.clone())
305 .unwrap_or_else(|| "unknown".to_string())
306}
307
308pub(crate) fn target_message_id_or_source_message_id(
310 state: &StreamTranslationState,
311) -> Option<&str> {
312 state
313 .target_message_id
314 .as_deref()
315 .or(state.message_id.as_deref())
316}
317
318pub(crate) fn state_source_is(state: &StreamTranslationState, format: WireFormat) -> bool {
320 let format_id: FormatId = format.into();
321 match &state.source {
322 Some(source) => source == &format_id,
323 None => false,
324 }
325}
326
327pub(crate) fn string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
329 object
330 .get(key)
331 .and_then(Value::as_str)
332 .filter(|value| !value.is_empty())
333 .map(ToOwned::to_owned)
334}