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 | "image_generation_call"
114 )
115 )
116}
117
118pub(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
135pub fn stable_id(prefix: &str, counter: usize) -> String {
137 format!("{prefix}_{counter:08}")
138}
139
140pub 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
148pub 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
160pub 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
243fn 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
253fn 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
263fn 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
277pub 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
290pub 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
303pub 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
315pub 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
327pub 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
353fn 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
384fn 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
397fn 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
417fn 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
441pub 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
470pub 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
480pub 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
497pub 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
513pub(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
526fn 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
550fn 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
592fn 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
612fn 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 #[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 #[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}