1use 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
20pub const SWITCHYARD_METADATA_KEY: &str = "_switchyard_translation";
22pub const PRESERVATION_METADATA_KEY: &str = SWITCHYARD_METADATA_KEY;
24
25const ANTHROPIC_TOOL_ID_ENCODING_PREFIX: &str = "sy64_";
26
27pub 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
37pub 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
55pub 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
63pub 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
86pub 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
102pub(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
119pub(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
136pub fn stable_id(prefix: &str, counter: usize) -> String {
138 format!("{prefix}_{counter:08}")
139}
140
141pub 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
149pub 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
161pub 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
244fn 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
254fn 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
264fn 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
278pub 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
291pub 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
304pub 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
316pub 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
328pub 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
361pub 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
390pub 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
400pub 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
417pub 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
433pub(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
446fn 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
470fn 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
512fn 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
532fn 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 #[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 #[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}