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 serde_json::{Map, Value, json};
9
10use crate::diagnostic::TranslationDiagnostic;
11use crate::error::{Result, TranslationError};
12use crate::format::FormatId;
13use crate::llm::{ContentBlock, LlmRequest, Message, PreservationMetadata};
14use crate::policy::{
15    LossyConversionPolicy, PreservationPolicy, TranslationPolicy, UnknownFieldPolicy,
16};
17
18/// Metadata key used to embed exact preserved payloads in provider JSON.
19pub const SWITCHYARD_METADATA_KEY: &str = "_switchyard_translation";
20/// Public alias for the embedded preservation metadata key.
21pub const PRESERVATION_METADATA_KEY: &str = SWITCHYARD_METADATA_KEY;
22
23/// Reads a JSON object or returns a typed translation error at the given path.
24pub fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map<String, Value>> {
25    value
26        .as_object()
27        .ok_or_else(|| TranslationError::InvalidType {
28            path: path.to_string(),
29            expected: "object",
30        })
31}
32
33/// Converts JSON scalars to Python-compatible string values where providers do so.
34pub fn string_value(value: &Value) -> Option<String> {
35    match value {
36        Value::String(text) => Some(text.clone()),
37        Value::Null => None,
38        other => Some(match other {
39            Value::Bool(value) => {
40                if *value {
41                    "True".to_string()
42                } else {
43                    "False".to_string()
44                }
45            }
46            _ => other.to_string(),
47        }),
48    }
49}
50
51/// Returns a non-empty string when the value is string-like enough to preserve.
52pub fn is_truthy_string(value: &Value) -> Option<String> {
53    match value {
54        Value::String(text) if !text.is_empty() => Some(text.clone()),
55        _ => None,
56    }
57}
58
59/// Applies the unknown-field policy and records diagnostics when configured.
60pub fn push_unknown_field(
61    diagnostics: &mut Vec<TranslationDiagnostic>,
62    policy: &TranslationPolicy,
63    path: impl Into<String>,
64) -> Result<()> {
65    let path = path.into();
66    match policy.unknown_field_policy {
67        UnknownFieldPolicy::Preserve => Ok(()),
68        UnknownFieldPolicy::DropWithWarning => {
69            diagnostics.push(
70                TranslationDiagnostic::warning(
71                    "unknown_field_dropped",
72                    format!("unknown field at {path} was dropped"),
73                )
74                .at_path(path),
75            );
76            Ok(())
77        }
78        UnknownFieldPolicy::Reject => Err(TranslationError::UnknownField { path }),
79    }
80}
81
82/// Applies the lossy-conversion policy and records diagnostics when configured.
83pub fn push_lossy(
84    diagnostics: &mut Vec<TranslationDiagnostic>,
85    policy: &TranslationPolicy,
86    message: impl Into<String>,
87) -> Result<()> {
88    let message = message.into();
89    match policy.lossy_conversion_policy {
90        LossyConversionPolicy::AllowWithDiagnostics => {
91            diagnostics.push(TranslationDiagnostic::warning("lossy_conversion", message));
92            Ok(())
93        }
94        LossyConversionPolicy::Reject => Err(TranslationError::LossyConversion(message)),
95    }
96}
97
98/// Generates a stable, human-readable ID from a prefix and counter.
99pub fn stable_id(prefix: &str, counter: usize) -> String {
100    format!("{prefix}_{counter:08}")
101}
102
103/// Serializes JSON values into provider argument strings.
104pub fn json_string(value: &Value) -> String {
105    match value {
106        Value::String(text) => text.clone(),
107        other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
108    }
109}
110
111/// Joins non-empty text fragments with a caller-provided separator.
112pub fn compact_text_blocks<'a>(
113    blocks: impl IntoIterator<Item = &'a str>,
114    separator: &str,
115) -> String {
116    blocks
117        .into_iter()
118        .filter(|part| !part.is_empty())
119        .collect::<Vec<_>>()
120        .join(separator)
121}
122
123/// Checks a request against declared target capabilities.
124pub fn validate_request_capabilities(
125    request: &LlmRequest,
126    diagnostics: &mut Vec<TranslationDiagnostic>,
127    policy: &TranslationPolicy,
128) -> Result<()> {
129    if policy.target_capabilities.supports_tools == Some(false)
130        && (!request.tools.is_empty() || messages_have_tools(&request.messages))
131    {
132        push_lossy(
133            diagnostics,
134            policy,
135            "target format/profile does not support tools",
136        )?;
137    }
138    if policy.target_capabilities.supports_images == Some(false)
139        && messages_have_block(&request.messages, |block| {
140            matches!(block, ContentBlock::Image { .. })
141        })
142    {
143        push_lossy(
144            diagnostics,
145            policy,
146            "target format/profile does not support images",
147        )?;
148    }
149    if policy.target_capabilities.supports_audio == Some(false)
150        && messages_have_block(&request.messages, |block| {
151            matches!(block, ContentBlock::Audio { .. })
152        })
153    {
154        push_lossy(
155            diagnostics,
156            policy,
157            "target format/profile does not support audio",
158        )?;
159    }
160    if policy.target_capabilities.supports_video == Some(false)
161        && messages_have_block(&request.messages, |block| {
162            matches!(block, ContentBlock::Video { .. })
163        })
164    {
165        push_lossy(
166            diagnostics,
167            policy,
168            "target format/profile does not support video",
169        )?;
170    }
171    if policy.target_capabilities.supports_files == Some(false)
172        && messages_have_block(&request.messages, |block| {
173            matches!(block, ContentBlock::File { .. })
174        })
175    {
176        push_lossy(
177            diagnostics,
178            policy,
179            "target format/profile does not support files",
180        )?;
181    }
182    if policy.target_capabilities.supports_reasoning_effort == Some(false)
183        && request.reasoning.effort.is_some()
184    {
185        push_lossy(
186            diagnostics,
187            policy,
188            "target format/profile does not support reasoning effort",
189        )?;
190    }
191    if policy
192        .target_capabilities
193        .supports_json_schema_response_format
194        == Some(false)
195        && request.output.response_format.is_some()
196    {
197        push_lossy(
198            diagnostics,
199            policy,
200            "target format/profile does not support structured response formats",
201        )?;
202    }
203    Ok(())
204}
205
206// Detects whether any message carries tool calls or tool results.
207fn messages_have_tools(messages: &[Message]) -> bool {
208    messages_have_block(messages, |block| {
209        matches!(
210            block,
211            ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_)
212        )
213    })
214}
215
216// Scans message content for a caller-provided block predicate.
217fn messages_have_block(messages: &[Message], predicate: impl FnMut(&ContentBlock) -> bool) -> bool {
218    messages
219        .iter()
220        .flat_map(|message| message.content.iter())
221        .any(predicate)
222}
223
224/// Captures an exact source request body according to preservation policy.
225pub fn capture_request_preservation(
226    format: impl Into<FormatId>,
227    body: &Value,
228    policy: &TranslationPolicy,
229) -> PreservationMetadata {
230    let mut preservation = extract_preservation(body);
231    if policy.preservation != PreservationPolicy::Disabled {
232        preservation.requests.insert(format.into(), body.clone());
233    }
234    preservation
235}
236
237/// Captures an exact source response body according to preservation policy.
238pub fn capture_response_preservation(
239    format: impl Into<FormatId>,
240    body: &Value,
241    policy: &TranslationPolicy,
242) -> PreservationMetadata {
243    let mut preservation = extract_preservation(body);
244    if policy.preservation != PreservationPolicy::Disabled {
245        preservation.responses.insert(format.into(), body.clone());
246    }
247    preservation
248}
249
250/// Returns an exact preserved request for the target format when available.
251pub fn exact_preserved_request(
252    preservation: &PreservationMetadata,
253    format: impl Into<FormatId>,
254    policy: &TranslationPolicy,
255) -> Option<Value> {
256    let format = format.into();
257    (policy.preservation != PreservationPolicy::Disabled)
258        .then(|| preservation.requests.get(&format).cloned())
259        .flatten()
260}
261
262/// Returns an exact preserved response for the target format when available.
263pub fn exact_preserved_response(
264    preservation: &PreservationMetadata,
265    format: impl Into<FormatId>,
266    policy: &TranslationPolicy,
267) -> Option<Value> {
268    let format = format.into();
269    (policy.preservation != PreservationPolicy::Disabled)
270        .then(|| preservation.responses.get(&format).cloned())
271        .flatten()
272}
273
274/// Embeds preservation metadata into a translated wire body when requested.
275pub fn embed_preservation(
276    mut body: Value,
277    preservation: &PreservationMetadata,
278    policy: &TranslationPolicy,
279) -> Value {
280    if policy.preservation != PreservationPolicy::Embed {
281        return body;
282    }
283    let Ok(envelope) = serde_json::to_value(preservation) else {
284        return body;
285    };
286    let metadata = json!({SWITCHYARD_METADATA_KEY: envelope});
287    if let Some(object) = body.as_object_mut() {
288        match object.get_mut("metadata") {
289            Some(Value::Object(existing)) => {
290                existing.insert(
291                    SWITCHYARD_METADATA_KEY.to_string(),
292                    metadata[SWITCHYARD_METADATA_KEY].clone(),
293                );
294            }
295            _ => {
296                object.insert("metadata".to_string(), metadata);
297            }
298        }
299    }
300    body
301}
302
303/// Extracts embedded preservation metadata from a provider wire body.
304pub fn extract_preservation(body: &Value) -> PreservationMetadata {
305    body.get("metadata")
306        .and_then(Value::as_object)
307        .and_then(|metadata| metadata.get(SWITCHYARD_METADATA_KEY))
308        .cloned()
309        .and_then(|value| serde_json::from_value(value).ok())
310        .unwrap_or_default()
311}
312
313/// Normalizes Anthropic tool-use IDs while keeping tool_use/tool_result pairs aligned.
314pub fn normalize_anthropic_tool_use_ids(value: Value) -> Value {
315    match value {
316        Value::Array(messages) => {
317            let mut id_map = BTreeMap::new();
318            let mut used_ids = BTreeMap::new();
319            Value::Array(
320                messages
321                    .into_iter()
322                    .map(|message| normalize_message_tool_ids(message, &mut id_map, &mut used_ids))
323                    .collect(),
324            )
325        }
326        other => other,
327    }
328}
329
330/// Converts a single ID into Anthropic-safe characters.
331pub fn sanitize_anthropic_tool_use_id(raw: &str) -> String {
332    let sanitized = raw
333        .chars()
334        .map(|ch| {
335            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
336                ch
337            } else {
338                '_'
339            }
340        })
341        .collect::<String>();
342    if sanitized.is_empty() {
343        "toolu_empty".to_string()
344    } else {
345        sanitized
346    }
347}
348
349// Normalizes every content block in one Anthropic message.
350fn normalize_message_tool_ids(
351    message: Value,
352    id_map: &mut BTreeMap<String, String>,
353    used_ids: &mut BTreeMap<String, String>,
354) -> Value {
355    let Value::Object(mut message) = message else {
356        return message;
357    };
358    let Some(content_value) = message.remove("content") else {
359        return Value::Object(message);
360    };
361    let Value::Array(content) = content_value else {
362        message.insert("content".to_string(), content_value);
363        return Value::Object(message);
364    };
365    let normalized = content
366        .into_iter()
367        .map(|block| normalize_tool_block(block, id_map, used_ids).unwrap_or_else(|block| block))
368        .collect::<Vec<_>>();
369    message.insert("content".to_string(), Value::Array(normalized));
370    Value::Object(message)
371}
372
373// Rewrites tool_use/tool_result IDs and leaves unrelated blocks untouched.
374fn normalize_tool_block(
375    block: Value,
376    id_map: &mut BTreeMap<String, String>,
377    used_ids: &mut BTreeMap<String, String>,
378) -> std::result::Result<Value, Value> {
379    let Value::Object(mut block_map) = block else {
380        return Err(block);
381    };
382    match block_map.get("type").and_then(Value::as_str) {
383        Some("tool_use") => {
384            let raw = block_map
385                .get("id")
386                .and_then(Value::as_str)
387                .unwrap_or_default()
388                .to_string();
389            let normalized = mapped_tool_id(&raw, id_map, used_ids);
390            if normalized != raw {
391                block_map.insert("id".to_string(), Value::String(normalized));
392                Ok(Value::Object(block_map))
393            } else {
394                Err(Value::Object(block_map))
395            }
396        }
397        Some("tool_result") => {
398            let raw = block_map
399                .get("tool_use_id")
400                .and_then(Value::as_str)
401                .unwrap_or_default()
402                .to_string();
403            let normalized = mapped_tool_id(&raw, id_map, used_ids);
404            if normalized != raw {
405                block_map.insert("tool_use_id".to_string(), Value::String(normalized));
406                Ok(Value::Object(block_map))
407            } else {
408                Err(Value::Object(block_map))
409            }
410        }
411        _ => Err(Value::Object(block_map)),
412    }
413}
414
415// Gives colliding raw IDs stable, deterministic suffixes.
416fn mapped_tool_id(
417    raw: &str,
418    id_map: &mut BTreeMap<String, String>,
419    used_ids: &mut BTreeMap<String, String>,
420) -> String {
421    if let Some(existing) = id_map.get(raw) {
422        return existing.clone();
423    }
424    let mut candidate = sanitize_anthropic_tool_use_id(raw);
425    if let Some(owner) = used_ids.get(&candidate)
426        && owner != raw
427    {
428        candidate = format!("{}_{}", candidate, stable_suffix(raw));
429    }
430    id_map.insert(raw.to_string(), candidate.clone());
431    used_ids.insert(candidate.clone(), raw.to_string());
432    candidate
433}
434
435// Stable FNV-1a suffix for collision disambiguation.
436fn stable_suffix(raw: &str) -> String {
437    let mut hash: u64 = 1469598103934665603;
438    for byte in raw.as_bytes() {
439        hash ^= u64::from(*byte);
440        hash = hash.wrapping_mul(1099511628211);
441    }
442    format!("{hash:08x}")
443}