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