Skip to main content

switchyard_translation/
util.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared helpers for codec validation, diagnostics, and preservation metadata.
5
6use std::collections::BTreeMap;
7
8use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
9use serde_json::{Map, Value, json};
10use switchyard_protocol::ModelId;
11
12use crate::diagnostic::TranslationDiagnostic;
13use crate::error::{Result, TranslationError};
14use crate::format::{FormatId, WireFormat};
15use crate::llm::{ContentBlock, InstructionBlock, LlmRequest, Message, PreservationMetadata, Role};
16use crate::policy::{
17    LossyConversionPolicy, PreservationPolicy, TranslationPolicy, UnknownFieldPolicy,
18};
19
20/// Metadata key used to embed exact preserved payloads in provider JSON.
21pub const SWITCHYARD_METADATA_KEY: &str = "_switchyard_translation";
22/// Public alias for the embedded preservation metadata key.
23pub const PRESERVATION_METADATA_KEY: &str = SWITCHYARD_METADATA_KEY;
24
25const ANTHROPIC_TOOL_ID_ENCODING_PREFIX: &str = "sy64_";
26
27/// Reads a JSON object or returns a typed translation error at the given path.
28pub fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map<String, Value>> {
29    value
30        .as_object()
31        .ok_or_else(|| TranslationError::InvalidType {
32            path: path.to_string(),
33            expected: "object",
34        })
35}
36
37/// Converts JSON scalars to Python-compatible string values where providers do so.
38pub fn string_value(value: &Value) -> Option<String> {
39    match value {
40        Value::String(text) => Some(text.clone()),
41        Value::Null => None,
42        other => Some(match other {
43            Value::Bool(value) => {
44                if *value {
45                    "True".to_string()
46                } else {
47                    "False".to_string()
48                }
49            }
50            _ => other.to_string(),
51        }),
52    }
53}
54
55/// Returns a non-empty string when the value is string-like enough to preserve.
56pub fn is_truthy_string(value: &Value) -> Option<String> {
57    match value {
58        Value::String(text) if !text.is_empty() => Some(text.clone()),
59        _ => None,
60    }
61}
62
63/// Applies the unknown-field policy and records diagnostics when configured.
64pub fn push_unknown_field(
65    diagnostics: &mut Vec<TranslationDiagnostic>,
66    policy: &TranslationPolicy,
67    path: impl Into<String>,
68) -> Result<()> {
69    let path = path.into();
70    match policy.unknown_field_policy {
71        UnknownFieldPolicy::Preserve => Ok(()),
72        UnknownFieldPolicy::DropWithWarning => {
73            diagnostics.push(
74                TranslationDiagnostic::warning(
75                    "unknown_field_dropped",
76                    format!("unknown field at {path} was dropped"),
77                )
78                .at_path(path),
79            );
80            Ok(())
81        }
82        UnknownFieldPolicy::Reject => Err(TranslationError::UnknownField { path }),
83    }
84}
85
86/// Applies the lossy-conversion policy and records diagnostics when configured.
87pub fn push_lossy(
88    diagnostics: &mut Vec<TranslationDiagnostic>,
89    policy: &TranslationPolicy,
90    message: impl Into<String>,
91) -> Result<()> {
92    let message = message.into();
93    match policy.lossy_conversion_policy {
94        LossyConversionPolicy::AllowWithDiagnostics => {
95            diagnostics.push(TranslationDiagnostic::warning("lossy_conversion", message));
96            Ok(())
97        }
98        LossyConversionPolicy::Reject => Err(TranslationError::LossyConversion(message)),
99    }
100}
101
102// These Responses history items are valid only as top-level `input` items.
103pub(crate) fn is_responses_builtin_tool_item(item: &Value) -> bool {
104    matches!(
105        item.get("type").and_then(Value::as_str),
106        Some(
107            "apply_patch_call"
108                | "apply_patch_call_output"
109                | "shell_call"
110                | "shell_call_output"
111                | "computer_call"
112                | "computer_call_output"
113        )
114    )
115}
116
117// Prevent provider-specific tool history from being downgraded to target-visible prose.
118pub(crate) fn reject_responses_builtin_tool_item(
119    provider: &FormatId,
120    item: &Value,
121    target: WireFormat,
122) -> Result<()> {
123    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
124        && is_responses_builtin_tool_item(item)
125    {
126        return Err(TranslationError::UnsupportedTranslation {
127            from: WireFormat::OpenAiResponses.into(),
128            to: target.into(),
129        });
130    }
131    Ok(())
132}
133
134/// Generates a stable, human-readable ID from a prefix and counter.
135pub fn stable_id(prefix: &str, counter: usize) -> String {
136    format!("{prefix}_{counter:08}")
137}
138
139/// Serializes JSON values into provider argument strings.
140pub fn json_string(value: &Value) -> String {
141    match value {
142        Value::String(text) => text.clone(),
143        other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
144    }
145}
146
147/// Joins non-empty text fragments with a caller-provided separator.
148pub fn compact_text_blocks<'a>(
149    blocks: impl IntoIterator<Item = &'a str>,
150    separator: &str,
151) -> String {
152    blocks
153        .into_iter()
154        .filter(|part| !part.is_empty())
155        .collect::<Vec<_>>()
156        .join(separator)
157}
158
159/// Checks a request against declared target capabilities.
160pub fn validate_request_capabilities(
161    request: &LlmRequest,
162    diagnostics: &mut Vec<TranslationDiagnostic>,
163    policy: &TranslationPolicy,
164) -> Result<()> {
165    if policy.target_capabilities.supports_tools == Some(false)
166        && (!request.tools.is_empty() || messages_have_tools(&request.messages))
167    {
168        push_lossy(
169            diagnostics,
170            policy,
171            "target format/profile does not support tools",
172        )?;
173    }
174    if policy.target_capabilities.supports_images == Some(false)
175        && messages_have_block(&request.messages, |block| {
176            matches!(block, ContentBlock::Image { .. })
177        })
178    {
179        push_lossy(
180            diagnostics,
181            policy,
182            "target format/profile does not support images",
183        )?;
184    }
185    if policy.target_capabilities.supports_audio == Some(false)
186        && messages_have_block(&request.messages, |block| {
187            matches!(block, ContentBlock::Audio { .. })
188        })
189    {
190        push_lossy(
191            diagnostics,
192            policy,
193            "target format/profile does not support audio",
194        )?;
195    }
196    if policy.target_capabilities.supports_video == Some(false)
197        && messages_have_block(&request.messages, |block| {
198            matches!(block, ContentBlock::Video { .. })
199        })
200    {
201        push_lossy(
202            diagnostics,
203            policy,
204            "target format/profile does not support video",
205        )?;
206    }
207    if policy.target_capabilities.supports_files == Some(false)
208        && messages_have_block(&request.messages, |block| {
209            matches!(block, ContentBlock::File { .. })
210        })
211    {
212        push_lossy(
213            diagnostics,
214            policy,
215            "target format/profile does not support files",
216        )?;
217    }
218    if policy.target_capabilities.supports_reasoning_effort == Some(false)
219        && request.reasoning.effort.is_some()
220    {
221        push_lossy(
222            diagnostics,
223            policy,
224            "target format/profile does not support reasoning effort",
225        )?;
226    }
227    if policy
228        .target_capabilities
229        .supports_json_schema_response_format
230        == Some(false)
231        && request.output.response_format.is_some()
232    {
233        push_lossy(
234            diagnostics,
235            policy,
236            "target format/profile does not support structured response formats",
237        )?;
238    }
239    Ok(())
240}
241
242// Detects whether any message carries tool calls or tool results.
243fn messages_have_tools(messages: &[Message]) -> bool {
244    messages_have_block(messages, |block| {
245        matches!(
246            block,
247            ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_)
248        )
249    })
250}
251
252// Scans message content for a caller-provided block predicate.
253fn messages_have_block(
254    messages: &[Message],
255    mut predicate: impl FnMut(&ContentBlock) -> bool,
256) -> bool {
257    messages
258        .iter()
259        .any(|message| content_has_block(&message.content, &mut predicate))
260}
261
262// Scans content recursively because tool results can contain media blocks.
263fn content_has_block(
264    content: &[ContentBlock],
265    predicate: &mut impl FnMut(&ContentBlock) -> bool,
266) -> bool {
267    content.iter().any(|block| {
268        predicate(block)
269            || match block {
270                ContentBlock::ToolResult(result) => content_has_block(&result.content, predicate),
271                _ => false,
272            }
273    })
274}
275
276/// Captures an exact source request body according to preservation policy.
277pub fn capture_request_preservation(
278    format: impl Into<FormatId>,
279    body: &Value,
280    policy: &TranslationPolicy,
281) -> PreservationMetadata {
282    let mut preservation = extract_preservation(body);
283    if policy.preservation != PreservationPolicy::Disabled {
284        preservation.requests.insert(format.into(), body.clone());
285    }
286    preservation
287}
288
289/// Captures an exact source response body according to preservation policy.
290pub fn capture_response_preservation(
291    format: impl Into<FormatId>,
292    body: &Value,
293    policy: &TranslationPolicy,
294) -> PreservationMetadata {
295    let mut preservation = extract_preservation(body);
296    if policy.preservation != PreservationPolicy::Disabled {
297        preservation.responses.insert(format.into(), body.clone());
298    }
299    preservation
300}
301
302/// Returns an exact preserved request for the target format when available.
303pub fn exact_preserved_request(
304    preservation: &PreservationMetadata,
305    format: impl Into<FormatId>,
306    policy: &TranslationPolicy,
307) -> Option<Value> {
308    let format = format.into();
309    (policy.preservation != PreservationPolicy::Disabled)
310        .then(|| preservation.requests.get(&format).cloned())
311        .flatten()
312}
313
314/// Returns an exact preserved response for the target format when available.
315pub fn exact_preserved_response(
316    preservation: &PreservationMetadata,
317    format: impl Into<FormatId>,
318    policy: &TranslationPolicy,
319) -> Option<Value> {
320    let format = format.into();
321    (policy.preservation != PreservationPolicy::Disabled)
322        .then(|| preservation.responses.get(&format).cloned())
323        .flatten()
324}
325
326/// Applies a selected target model and optionally prepends its system prompt.
327///
328/// Preserved built-in bodies are updated in their native format when possible so exact replay
329/// keeps caller fields that are not represented in the normalized request.
330/// Call this once per candidate using a request that has not already received a target prompt.
331pub fn prepare_request_for_target(
332    request: &mut LlmRequest,
333    target: &ModelId,
334    prompt: Option<&str>,
335) {
336    let target = target.to_string();
337    request.model = Some(target.clone());
338    if let Some(prompt) = prompt {
339        request.instructions.insert(
340            0,
341            InstructionBlock {
342                role: Role::System,
343                content: vec![ContentBlock::Text {
344                    text: prompt.to_string(),
345                }],
346            },
347        );
348    }
349    prepare_preserved_requests(&mut request.preservation, &target, prompt);
350}
351
352// Retains exact replay only where the built-in wire body can receive the target changes safely.
353fn prepare_preserved_requests(
354    preservation: &mut PreservationMetadata,
355    target: &str,
356    prompt: Option<&str>,
357) {
358    preservation.requests.retain(|format, body| {
359        let is_builtin = format.as_str() == WireFormat::OpenAiChat.as_str()
360            || format.as_str() == WireFormat::OpenAiResponses.as_str()
361            || format.as_str() == WireFormat::AnthropicMessages.as_str();
362        let Some(body) = is_builtin.then_some(body).and_then(Value::as_object_mut) else {
363            return false;
364        };
365        body.insert("model".to_string(), Value::String(target.to_string()));
366        match prompt {
367            None => true,
368            Some(prompt) if format.as_str() == WireFormat::OpenAiChat.as_str() => {
369                prepend_openai_chat_system(body, prompt)
370            }
371            Some(prompt) if format.as_str() == WireFormat::OpenAiResponses.as_str() => {
372                prepend_openai_responses_instructions(body, prompt)
373            }
374            Some(prompt) if format.as_str() == WireFormat::AnthropicMessages.as_str() => {
375                body.remove(crate::codecs::common::ANTHROPIC_REQUEST_KEY);
376                prepend_anthropic_system(body, prompt)
377            }
378            Some(_) => false,
379        }
380    });
381}
382
383// Prepends a target prompt without rebuilding or otherwise changing a Chat request.
384fn prepend_openai_chat_system(body: &mut Map<String, Value>, prompt: &str) -> bool {
385    let message = json!({"role": "system", "content": prompt});
386    match body.get_mut("messages") {
387        None | Some(Value::Null) => {
388            body.insert("messages".to_string(), Value::Array(vec![message]));
389        }
390        Some(Value::Array(messages)) => messages.insert(0, message),
391        Some(_) => return false,
392    }
393    true
394}
395
396// Prepends a target prompt without rebuilding or otherwise changing a Responses request.
397fn prepend_openai_responses_instructions(body: &mut Map<String, Value>, prompt: &str) -> bool {
398    match body.get_mut("instructions") {
399        None | Some(Value::Null) => {
400            body.insert(
401                "instructions".to_string(),
402                Value::String(prompt.to_string()),
403            );
404        }
405        Some(Value::String(instructions)) if instructions.is_empty() => {
406            *instructions = prompt.to_string();
407        }
408        Some(Value::String(instructions)) => {
409            instructions.insert_str(0, &format!("{prompt}\n\n"));
410        }
411        Some(_) => return false,
412    }
413    true
414}
415
416// Prepends a target prompt without rebuilding or otherwise changing an Anthropic request.
417fn prepend_anthropic_system(body: &mut Map<String, Value>, prompt: &str) -> bool {
418    match body.get_mut("system") {
419        None | Some(Value::Null) => {
420            body.insert("system".to_string(), Value::String(prompt.to_string()));
421        }
422        Some(Value::String(system)) if system.is_empty() => {
423            *system = prompt.to_string();
424        }
425        Some(Value::String(system)) => {
426            system.insert_str(0, &format!("{prompt}\n\n"));
427        }
428        Some(Value::Array(blocks)) => blocks.insert(
429            0,
430            json!({
431                "type": "text",
432                "text": prompt,
433            }),
434        ),
435        Some(_) => return false,
436    }
437    true
438}
439
440/// Embeds preservation metadata into a translated wire body when requested.
441pub fn embed_preservation(
442    mut body: Value,
443    preservation: &PreservationMetadata,
444    policy: &TranslationPolicy,
445) -> Value {
446    if policy.preservation != PreservationPolicy::Embed {
447        return body;
448    }
449    let Ok(envelope) = serde_json::to_value(preservation) else {
450        return body;
451    };
452    let metadata = json!({SWITCHYARD_METADATA_KEY: envelope});
453    if let Some(object) = body.as_object_mut() {
454        match object.get_mut("metadata") {
455            Some(Value::Object(existing)) => {
456                existing.insert(
457                    SWITCHYARD_METADATA_KEY.to_string(),
458                    metadata[SWITCHYARD_METADATA_KEY].clone(),
459                );
460            }
461            _ => {
462                object.insert("metadata".to_string(), metadata);
463            }
464        }
465    }
466    body
467}
468
469/// Extracts embedded preservation metadata from a provider wire body.
470pub fn extract_preservation(body: &Value) -> PreservationMetadata {
471    body.get("metadata")
472        .and_then(Value::as_object)
473        .and_then(|metadata| metadata.get(SWITCHYARD_METADATA_KEY))
474        .cloned()
475        .and_then(|value| serde_json::from_value(value).ok())
476        .unwrap_or_default()
477}
478
479/// Normalizes Anthropic tool-use IDs while keeping tool_use/tool_result pairs aligned.
480pub fn normalize_anthropic_tool_use_ids(value: Value) -> Value {
481    match value {
482        Value::Array(messages) => {
483            let mut id_map = BTreeMap::new();
484            let mut used_ids = BTreeMap::new();
485            Value::Array(
486                messages
487                    .into_iter()
488                    .map(|message| normalize_message_tool_ids(message, &mut id_map, &mut used_ids))
489                    .collect(),
490            )
491        }
492        other => other,
493    }
494}
495
496/// Converts an ID into a reversible Anthropic-safe representation.
497pub fn sanitize_anthropic_tool_use_id(raw: &str) -> String {
498    let is_safe = !raw.is_empty()
499        && raw
500            .bytes()
501            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-');
502    if is_safe && !raw.starts_with(ANTHROPIC_TOOL_ID_ENCODING_PREFIX) {
503        return raw.to_string();
504    }
505
506    format!(
507        "{ANTHROPIC_TOOL_ID_ENCODING_PREFIX}{}",
508        URL_SAFE_NO_PAD.encode(raw.as_bytes())
509    )
510}
511
512/// Restores an ID encoded by [`sanitize_anthropic_tool_use_id`].
513pub(crate) fn desanitize_anthropic_tool_use_id(encoded: &str) -> String {
514    let Some(payload) = encoded.strip_prefix(ANTHROPIC_TOOL_ID_ENCODING_PREFIX) else {
515        return encoded.to_string();
516    };
517
518    URL_SAFE_NO_PAD
519        .decode(payload)
520        .ok()
521        .and_then(|bytes| String::from_utf8(bytes).ok())
522        .unwrap_or_else(|| encoded.to_string())
523}
524
525// Normalizes every content block in one Anthropic message.
526fn normalize_message_tool_ids(
527    message: Value,
528    id_map: &mut BTreeMap<String, String>,
529    used_ids: &mut BTreeMap<String, String>,
530) -> Value {
531    let Value::Object(mut message) = message else {
532        return message;
533    };
534    let Some(content_value) = message.remove("content") else {
535        return Value::Object(message);
536    };
537    let Value::Array(content) = content_value else {
538        message.insert("content".to_string(), content_value);
539        return Value::Object(message);
540    };
541    let normalized = content
542        .into_iter()
543        .map(|block| normalize_tool_block(block, id_map, used_ids).unwrap_or_else(|block| block))
544        .collect::<Vec<_>>();
545    message.insert("content".to_string(), Value::Array(normalized));
546    Value::Object(message)
547}
548
549// Rewrites tool_use/tool_result IDs and leaves unrelated blocks untouched.
550fn normalize_tool_block(
551    block: Value,
552    id_map: &mut BTreeMap<String, String>,
553    used_ids: &mut BTreeMap<String, String>,
554) -> std::result::Result<Value, Value> {
555    let Value::Object(mut block_map) = block else {
556        return Err(block);
557    };
558    match block_map.get("type").and_then(Value::as_str) {
559        Some("tool_use") => {
560            let raw = block_map
561                .get("id")
562                .and_then(Value::as_str)
563                .unwrap_or_default()
564                .to_string();
565            let normalized = mapped_tool_id(&raw, id_map, used_ids);
566            if normalized != raw {
567                block_map.insert("id".to_string(), Value::String(normalized));
568                Ok(Value::Object(block_map))
569            } else {
570                Err(Value::Object(block_map))
571            }
572        }
573        Some("tool_result") => {
574            let raw = block_map
575                .get("tool_use_id")
576                .and_then(Value::as_str)
577                .unwrap_or_default()
578                .to_string();
579            let normalized = mapped_tool_id(&raw, id_map, used_ids);
580            if normalized != raw {
581                block_map.insert("tool_use_id".to_string(), Value::String(normalized));
582                Ok(Value::Object(block_map))
583            } else {
584                Err(Value::Object(block_map))
585            }
586        }
587        _ => Err(Value::Object(block_map)),
588    }
589}
590
591// Gives colliding raw IDs stable, deterministic suffixes.
592fn mapped_tool_id(
593    raw: &str,
594    id_map: &mut BTreeMap<String, String>,
595    used_ids: &mut BTreeMap<String, String>,
596) -> String {
597    if let Some(existing) = id_map.get(raw) {
598        return existing.clone();
599    }
600    let mut candidate = sanitize_anthropic_tool_use_id(raw);
601    if let Some(owner) = used_ids.get(&candidate)
602        && owner != raw
603    {
604        candidate = format!("{}_{}", candidate, stable_suffix(raw));
605    }
606    id_map.insert(raw.to_string(), candidate.clone());
607    used_ids.insert(candidate.clone(), raw.to_string());
608    candidate
609}
610
611// Stable FNV-1a suffix for collision disambiguation.
612fn stable_suffix(raw: &str) -> String {
613    let mut hash: u64 = 1469598103934665603;
614    for byte in raw.as_bytes() {
615        hash ^= u64::from(*byte);
616        hash = hash.wrapping_mul(1099511628211);
617    }
618    format!("{hash:08x}")
619}
620
621#[cfg(test)]
622mod tests {
623    use super::{desanitize_anthropic_tool_use_id, sanitize_anthropic_tool_use_id};
624
625    // Keeps ordinary provider IDs unchanged while making unsafe IDs reversible.
626    #[test]
627    fn anthropic_tool_id_encoding_round_trips() {
628        assert_eq!(
629            sanitize_anthropic_tool_use_id("call_abc-123"),
630            "call_abc-123"
631        );
632
633        for raw in ["", "functions.list_skills:0", "工具/lookup"] {
634            let encoded = sanitize_anthropic_tool_use_id(raw);
635            assert!(
636                encoded
637                    .bytes()
638                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
639            );
640            assert_eq!(desanitize_anthropic_tool_use_id(&encoded), raw);
641        }
642    }
643
644    // Escapes the reserved prefix and leaves malformed encoded values untouched.
645    #[test]
646    fn anthropic_tool_id_encoding_disambiguates_its_prefix() {
647        let raw = "sy64_Zm9v";
648        let encoded = sanitize_anthropic_tool_use_id(raw);
649        assert_ne!(encoded, raw);
650        assert_eq!(desanitize_anthropic_tool_use_id(&encoded), raw);
651        assert_eq!(desanitize_anthropic_tool_use_id("sy64_%%%"), "sy64_%%%");
652    }
653}