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, InstructionBlock, LlmRequest, Message, PreservationMetadata, Role};
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_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
117pub(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
134pub fn stable_id(prefix: &str, counter: usize) -> String {
136 format!("{prefix}_{counter:08}")
137}
138
139pub 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
147pub 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
159pub 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
242fn 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
252fn 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
262fn 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
276pub 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
289pub 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
302pub 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
314pub 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
326pub 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
352fn 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
383fn 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
396fn 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
416fn 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
440pub 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
469pub 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
479pub 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
496pub 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
512pub(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
525fn 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
549fn 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
591fn 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
611fn 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 #[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 #[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}