1use 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
18pub const SWITCHYARD_METADATA_KEY: &str = "_switchyard_translation";
20pub const PRESERVATION_METADATA_KEY: &str = SWITCHYARD_METADATA_KEY;
22
23pub 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
33pub 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
51pub 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
59pub 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
82pub 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
98pub fn stable_id(prefix: &str, counter: usize) -> String {
100 format!("{prefix}_{counter:08}")
101}
102
103pub 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
111pub 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
123pub 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
206fn 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
216fn 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
224pub 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
237pub 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
250pub 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
262pub 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
274pub 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
303pub 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
313pub 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
330pub 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
349fn 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
373fn 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
415fn 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
435fn 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}