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, LlmRequest, Message, PreservationMetadata};
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_native_input_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                | "image_generation_call"
114                | "compaction_trigger"
115        )
116    )
117}
118
119// Prevent provider-specific input from being downgraded to target-visible prose.
120pub(crate) fn reject_responses_native_input_item(
121    provider: &FormatId,
122    item: &Value,
123    target: WireFormat,
124) -> Result<()> {
125    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
126        && is_responses_native_input_item(item)
127    {
128        return Err(TranslationError::UnsupportedTranslation {
129            from: WireFormat::OpenAiResponses.into(),
130            to: target.into(),
131        });
132    }
133    Ok(())
134}
135
136/// Generates a stable, human-readable ID from a prefix and counter.
137pub fn stable_id(prefix: &str, counter: usize) -> String {
138    format!("{prefix}_{counter:08}")
139}
140
141/// Serializes JSON values into provider argument strings.
142pub fn json_string(value: &Value) -> String {
143    match value {
144        Value::String(text) => text.clone(),
145        other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
146    }
147}
148
149/// Joins non-empty text fragments with a caller-provided separator.
150pub fn compact_text_blocks<'a>(
151    blocks: impl IntoIterator<Item = &'a str>,
152    separator: &str,
153) -> String {
154    blocks
155        .into_iter()
156        .filter(|part| !part.is_empty())
157        .collect::<Vec<_>>()
158        .join(separator)
159}
160
161/// Checks a request against declared target capabilities.
162pub fn validate_request_capabilities(
163    request: &LlmRequest,
164    diagnostics: &mut Vec<TranslationDiagnostic>,
165    policy: &TranslationPolicy,
166) -> Result<()> {
167    if policy.target_capabilities.supports_tools == Some(false)
168        && (!request.tools.is_empty() || messages_have_tools(&request.messages))
169    {
170        push_lossy(
171            diagnostics,
172            policy,
173            "target format/profile does not support tools",
174        )?;
175    }
176    if policy.target_capabilities.supports_images == Some(false)
177        && messages_have_block(&request.messages, |block| {
178            matches!(block, ContentBlock::Image { .. })
179        })
180    {
181        push_lossy(
182            diagnostics,
183            policy,
184            "target format/profile does not support images",
185        )?;
186    }
187    if policy.target_capabilities.supports_audio == Some(false)
188        && messages_have_block(&request.messages, |block| {
189            matches!(block, ContentBlock::Audio { .. })
190        })
191    {
192        push_lossy(
193            diagnostics,
194            policy,
195            "target format/profile does not support audio",
196        )?;
197    }
198    if policy.target_capabilities.supports_video == Some(false)
199        && messages_have_block(&request.messages, |block| {
200            matches!(block, ContentBlock::Video { .. })
201        })
202    {
203        push_lossy(
204            diagnostics,
205            policy,
206            "target format/profile does not support video",
207        )?;
208    }
209    if policy.target_capabilities.supports_files == Some(false)
210        && messages_have_block(&request.messages, |block| {
211            matches!(block, ContentBlock::File { .. })
212        })
213    {
214        push_lossy(
215            diagnostics,
216            policy,
217            "target format/profile does not support files",
218        )?;
219    }
220    if policy.target_capabilities.supports_reasoning_effort == Some(false)
221        && request.reasoning.effort.is_some()
222    {
223        push_lossy(
224            diagnostics,
225            policy,
226            "target format/profile does not support reasoning effort",
227        )?;
228    }
229    if policy
230        .target_capabilities
231        .supports_json_schema_response_format
232        == Some(false)
233        && request.output.response_format.is_some()
234    {
235        push_lossy(
236            diagnostics,
237            policy,
238            "target format/profile does not support structured response formats",
239        )?;
240    }
241    Ok(())
242}
243
244// Detects whether any message carries tool calls or tool results.
245fn messages_have_tools(messages: &[Message]) -> bool {
246    messages_have_block(messages, |block| {
247        matches!(
248            block,
249            ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_)
250        )
251    })
252}
253
254// Scans message content for a caller-provided block predicate.
255fn messages_have_block(
256    messages: &[Message],
257    mut predicate: impl FnMut(&ContentBlock) -> bool,
258) -> bool {
259    messages
260        .iter()
261        .any(|message| content_has_block(&message.content, &mut predicate))
262}
263
264// Scans content recursively because tool results can contain media blocks.
265fn content_has_block(
266    content: &[ContentBlock],
267    predicate: &mut impl FnMut(&ContentBlock) -> bool,
268) -> bool {
269    content.iter().any(|block| {
270        predicate(block)
271            || match block {
272                ContentBlock::ToolResult(result) => content_has_block(&result.content, predicate),
273                _ => false,
274            }
275    })
276}
277
278/// Captures an exact source request body according to preservation policy.
279pub fn capture_request_preservation(
280    format: impl Into<FormatId>,
281    body: &Value,
282    policy: &TranslationPolicy,
283) -> PreservationMetadata {
284    let mut preservation = extract_preservation(body);
285    if policy.preservation != PreservationPolicy::Disabled {
286        preservation.requests.insert(format.into(), body.clone());
287    }
288    preservation
289}
290
291/// Captures an exact source response body according to preservation policy.
292pub fn capture_response_preservation(
293    format: impl Into<FormatId>,
294    body: &Value,
295    policy: &TranslationPolicy,
296) -> PreservationMetadata {
297    let mut preservation = extract_preservation(body);
298    if policy.preservation != PreservationPolicy::Disabled {
299        preservation.responses.insert(format.into(), body.clone());
300    }
301    preservation
302}
303
304/// Returns an exact preserved request for the target format when available.
305pub fn exact_preserved_request(
306    preservation: &PreservationMetadata,
307    format: impl Into<FormatId>,
308    policy: &TranslationPolicy,
309) -> Option<Value> {
310    let format = format.into();
311    (policy.preservation != PreservationPolicy::Disabled)
312        .then(|| preservation.requests.get(&format).cloned())
313        .flatten()
314}
315
316/// Returns an exact preserved response for the target format when available.
317pub fn exact_preserved_response(
318    preservation: &PreservationMetadata,
319    format: impl Into<FormatId>,
320    policy: &TranslationPolicy,
321) -> Option<Value> {
322    let format = format.into();
323    (policy.preservation != PreservationPolicy::Disabled)
324        .then(|| preservation.responses.get(&format).cloned())
325        .flatten()
326}
327
328/// Applies a selected target model and optionally prepends its system prompt.
329///
330/// Preserved built-in bodies are updated in their native format when possible so exact replay
331/// keeps caller fields that are not represented in the normalized request.
332/// Call this once per candidate using a request that has not already received a target prompt.
333pub fn prepare_request_for_target(
334    request: &mut LlmRequest,
335    target: &ModelId,
336    prompt: Option<&str>,
337) {
338    let target = target.to_string();
339    request.model = Some(target.clone());
340    if let Some(prompt) = prompt {
341        crate::prepend_system_prompt(request, prompt);
342    }
343    request.preservation.requests.retain(|format, body| {
344        if ![
345            WireFormat::OpenAiChat.as_str(),
346            WireFormat::OpenAiResponses.as_str(),
347            WireFormat::AnthropicMessages.as_str(),
348        ]
349        .contains(&format.as_str())
350        {
351            return false;
352        }
353        let Some(body) = body.as_object_mut() else {
354            return false;
355        };
356        body.insert("model".to_string(), Value::String(target.clone()));
357        true
358    });
359}
360
361/// Embeds preservation metadata into a translated wire body when requested.
362pub fn embed_preservation(
363    mut body: Value,
364    preservation: &PreservationMetadata,
365    policy: &TranslationPolicy,
366) -> Value {
367    if policy.preservation != PreservationPolicy::Embed {
368        return body;
369    }
370    let Ok(envelope) = serde_json::to_value(preservation) else {
371        return body;
372    };
373    let metadata = json!({SWITCHYARD_METADATA_KEY: envelope});
374    if let Some(object) = body.as_object_mut() {
375        match object.get_mut("metadata") {
376            Some(Value::Object(existing)) => {
377                existing.insert(
378                    SWITCHYARD_METADATA_KEY.to_string(),
379                    metadata[SWITCHYARD_METADATA_KEY].clone(),
380                );
381            }
382            _ => {
383                object.insert("metadata".to_string(), metadata);
384            }
385        }
386    }
387    body
388}
389
390/// Extracts embedded preservation metadata from a provider wire body.
391pub fn extract_preservation(body: &Value) -> PreservationMetadata {
392    body.get("metadata")
393        .and_then(Value::as_object)
394        .and_then(|metadata| metadata.get(SWITCHYARD_METADATA_KEY))
395        .cloned()
396        .and_then(|value| serde_json::from_value(value).ok())
397        .unwrap_or_default()
398}
399
400/// Normalizes Anthropic tool-use IDs while keeping tool_use/tool_result pairs aligned.
401pub fn normalize_anthropic_tool_use_ids(value: Value) -> Value {
402    match value {
403        Value::Array(messages) => {
404            let mut id_map = BTreeMap::new();
405            let mut used_ids = BTreeMap::new();
406            Value::Array(
407                messages
408                    .into_iter()
409                    .map(|message| normalize_message_tool_ids(message, &mut id_map, &mut used_ids))
410                    .collect(),
411            )
412        }
413        other => other,
414    }
415}
416
417/// Converts an ID into a reversible Anthropic-safe representation.
418pub fn sanitize_anthropic_tool_use_id(raw: &str) -> String {
419    let is_safe = !raw.is_empty()
420        && raw
421            .bytes()
422            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-');
423    if is_safe && !raw.starts_with(ANTHROPIC_TOOL_ID_ENCODING_PREFIX) {
424        return raw.to_string();
425    }
426
427    format!(
428        "{ANTHROPIC_TOOL_ID_ENCODING_PREFIX}{}",
429        URL_SAFE_NO_PAD.encode(raw.as_bytes())
430    )
431}
432
433/// Restores an ID encoded by [`sanitize_anthropic_tool_use_id`].
434pub(crate) fn desanitize_anthropic_tool_use_id(encoded: &str) -> String {
435    let Some(payload) = encoded.strip_prefix(ANTHROPIC_TOOL_ID_ENCODING_PREFIX) else {
436        return encoded.to_string();
437    };
438
439    URL_SAFE_NO_PAD
440        .decode(payload)
441        .ok()
442        .and_then(|bytes| String::from_utf8(bytes).ok())
443        .unwrap_or_else(|| encoded.to_string())
444}
445
446// Normalizes every content block in one Anthropic message.
447fn normalize_message_tool_ids(
448    message: Value,
449    id_map: &mut BTreeMap<String, String>,
450    used_ids: &mut BTreeMap<String, String>,
451) -> Value {
452    let Value::Object(mut message) = message else {
453        return message;
454    };
455    let Some(content_value) = message.remove("content") else {
456        return Value::Object(message);
457    };
458    let Value::Array(content) = content_value else {
459        message.insert("content".to_string(), content_value);
460        return Value::Object(message);
461    };
462    let normalized = content
463        .into_iter()
464        .map(|block| normalize_tool_block(block, id_map, used_ids).unwrap_or_else(|block| block))
465        .collect::<Vec<_>>();
466    message.insert("content".to_string(), Value::Array(normalized));
467    Value::Object(message)
468}
469
470// Rewrites tool_use/tool_result IDs and leaves unrelated blocks untouched.
471fn normalize_tool_block(
472    block: Value,
473    id_map: &mut BTreeMap<String, String>,
474    used_ids: &mut BTreeMap<String, String>,
475) -> std::result::Result<Value, Value> {
476    let Value::Object(mut block_map) = block else {
477        return Err(block);
478    };
479    match block_map.get("type").and_then(Value::as_str) {
480        Some("tool_use") => {
481            let raw = block_map
482                .get("id")
483                .and_then(Value::as_str)
484                .unwrap_or_default()
485                .to_string();
486            let normalized = mapped_tool_id(&raw, id_map, used_ids);
487            if normalized != raw {
488                block_map.insert("id".to_string(), Value::String(normalized));
489                Ok(Value::Object(block_map))
490            } else {
491                Err(Value::Object(block_map))
492            }
493        }
494        Some("tool_result") => {
495            let raw = block_map
496                .get("tool_use_id")
497                .and_then(Value::as_str)
498                .unwrap_or_default()
499                .to_string();
500            let normalized = mapped_tool_id(&raw, id_map, used_ids);
501            if normalized != raw {
502                block_map.insert("tool_use_id".to_string(), Value::String(normalized));
503                Ok(Value::Object(block_map))
504            } else {
505                Err(Value::Object(block_map))
506            }
507        }
508        _ => Err(Value::Object(block_map)),
509    }
510}
511
512// Gives colliding raw IDs stable, deterministic suffixes.
513fn mapped_tool_id(
514    raw: &str,
515    id_map: &mut BTreeMap<String, String>,
516    used_ids: &mut BTreeMap<String, String>,
517) -> String {
518    if let Some(existing) = id_map.get(raw) {
519        return existing.clone();
520    }
521    let mut candidate = sanitize_anthropic_tool_use_id(raw);
522    if let Some(owner) = used_ids.get(&candidate)
523        && owner != raw
524    {
525        candidate = format!("{}_{}", candidate, stable_suffix(raw));
526    }
527    id_map.insert(raw.to_string(), candidate.clone());
528    used_ids.insert(candidate.clone(), raw.to_string());
529    candidate
530}
531
532// Stable FNV-1a suffix for collision disambiguation.
533fn stable_suffix(raw: &str) -> String {
534    let mut hash: u64 = 1469598103934665603;
535    for byte in raw.as_bytes() {
536        hash ^= u64::from(*byte);
537        hash = hash.wrapping_mul(1099511628211);
538    }
539    format!("{hash:08x}")
540}
541
542#[cfg(test)]
543mod tests {
544    use super::{desanitize_anthropic_tool_use_id, sanitize_anthropic_tool_use_id};
545
546    // Keeps ordinary provider IDs unchanged while making unsafe IDs reversible.
547    #[test]
548    fn anthropic_tool_id_encoding_round_trips() {
549        assert_eq!(
550            sanitize_anthropic_tool_use_id("call_abc-123"),
551            "call_abc-123"
552        );
553
554        for raw in ["", "functions.list_skills:0", "工具/lookup"] {
555            let encoded = sanitize_anthropic_tool_use_id(raw);
556            assert!(
557                encoded
558                    .bytes()
559                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
560            );
561            assert_eq!(desanitize_anthropic_tool_use_id(&encoded), raw);
562        }
563    }
564
565    // Escapes the reserved prefix and leaves malformed encoded values untouched.
566    #[test]
567    fn anthropic_tool_id_encoding_disambiguates_its_prefix() {
568        let raw = "sy64_Zm9v";
569        let encoded = sanitize_anthropic_tool_use_id(raw);
570        assert_ne!(encoded, raw);
571        assert_eq!(desanitize_anthropic_tool_use_id(&encoded), raw);
572        assert_eq!(desanitize_anthropic_tool_use_id("sy64_%%%"), "sy64_%%%");
573    }
574}