switchyard_llm_client/
responses_compatibility.rs1use serde::Deserialize;
7use serde_json::{Map, Value};
8use std::collections::HashMap;
9
10#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
12#[serde(rename_all = "snake_case")]
13pub enum ResponsesCompatibility {
14 #[default]
16 Native,
17 FlatTools,
19 Kimi,
21}
22
23impl ResponsesCompatibility {
24 pub(crate) fn normalize(self, body: &mut Value) {
25 if self == Self::Native {
26 return;
27 }
28 sanitize_openai_responses_provider_body(body);
29 if self == Self::Kimi {
30 pair_kimi_tool_outputs(body);
31 }
32 }
33}
34
35fn pair_kimi_tool_outputs(body: &mut Value) {
38 let Some(Value::Array(items)) = body.get_mut("input") else {
39 return;
40 };
41 let names: HashMap<String, Value> = items
42 .iter()
43 .filter(|item| {
44 matches!(
45 item.get("type").and_then(Value::as_str),
46 Some("function_call" | "custom_tool_call")
47 )
48 })
49 .filter_map(|item| {
50 Some((
51 item.get("call_id")?.as_str()?.to_string(),
52 item.get("name")?.clone(),
53 ))
54 })
55 .collect();
56 for item in items.iter_mut() {
57 if matches!(
58 item.get("type").and_then(Value::as_str),
59 Some("function_call_output" | "custom_tool_call_output")
60 ) && let Some(name) = item
61 .get("call_id")
62 .and_then(Value::as_str)
63 .and_then(|id| names.get(id))
64 && let Some(item) = item.as_object_mut()
65 {
66 item.entry("name".to_string())
67 .or_insert_with(|| name.clone());
68 }
69 }
70 let mut index = 0;
71 while index < items.len() {
72 let output_kind = match items[index].get("type").and_then(Value::as_str) {
73 Some("function_call") => "function_call_output",
74 Some("custom_tool_call") => "custom_tool_call_output",
75 _ => {
76 index += 1;
77 continue;
78 }
79 };
80 let Some(call_id) = items[index].get("call_id").and_then(Value::as_str) else {
81 index += 1;
82 continue;
83 };
84 let output = items
85 .iter()
86 .enumerate()
87 .skip(index + 1)
88 .take_while(|(_, item)| {
89 item.get("role").is_none()
90 && item.get("type").and_then(Value::as_str) != Some("message")
91 })
92 .find(|(_, item)| {
93 item.get("type").and_then(Value::as_str) == Some(output_kind)
94 && item.get("call_id").and_then(Value::as_str) == Some(call_id)
95 })
96 .map(|(position, _)| position);
97 if let Some(output) = output {
98 let item = items.remove(output);
99 items.insert(index + 1, item);
100 }
101 index += 1;
102 }
103}
104
105const CODEX_NAMESPACE_SEPARATOR: &str = "__";
106
107fn sanitize_openai_responses_provider_body(body: &mut Value) {
110 let Value::Object(object) = body else {
111 return;
112 };
113 sanitize_openai_responses_input_for_provider(object.get_mut("input"));
114 sanitize_openai_responses_tools_for_provider(object.get_mut("tools"));
115 sanitize_openai_responses_tool_choice_for_provider(object.get_mut("tool_choice"));
116}
117
118fn sanitize_openai_responses_input_for_provider(input: Option<&mut Value>) {
119 let Some(Value::Array(items)) = input else {
120 return;
121 };
122 for item in items {
123 let Some(object) = item.as_object_mut() else {
124 continue;
125 };
126 if object.get("type").and_then(Value::as_str) == Some("function_call") {
127 qualify_responses_function_name(object);
128 }
129 }
130}
131
132fn sanitize_openai_responses_tools_for_provider(tools: Option<&mut Value>) {
133 let Some(Value::Array(tools)) = tools else {
134 return;
135 };
136 let mut flat_tools = Vec::with_capacity(tools.len());
137 for tool in std::mem::take(tools) {
138 push_sanitized_openai_responses_tool(&mut flat_tools, tool);
139 }
140 *tools = flat_tools;
141}
142
143fn push_sanitized_openai_responses_tool(out: &mut Vec<Value>, tool: Value) {
144 let Value::Object(mut object) = tool else {
145 out.push(tool);
146 return;
147 };
148 if object.get("type").and_then(Value::as_str) == Some("namespace") {
149 let namespace = object
150 .get("name")
151 .and_then(Value::as_str)
152 .unwrap_or_default()
153 .to_string();
154 let Some(Value::Array(children)) = object.remove("tools") else {
155 out.push(Value::Object(object));
156 return;
157 };
158 for child in children {
159 push_sanitized_namespaced_tool(out, &namespace, child);
160 }
161 return;
162 }
163 ensure_responses_function_tool_description(&mut object);
164 out.push(Value::Object(object));
165}
166
167fn push_sanitized_namespaced_tool(out: &mut Vec<Value>, namespace: &str, tool: Value) {
168 let Value::Object(mut object) = tool else {
169 out.push(tool);
170 return;
171 };
172 if object.get("type").and_then(Value::as_str) == Some("function") {
173 qualify_responses_function_name_with_namespace(&mut object, namespace);
174 ensure_responses_function_tool_description(&mut object);
175 }
176 out.push(Value::Object(object));
177}
178
179fn sanitize_openai_responses_tool_choice_for_provider(tool_choice: Option<&mut Value>) {
180 let Some(Value::Object(object)) = tool_choice else {
181 return;
182 };
183 if object.get("type").and_then(Value::as_str) == Some("function") {
184 qualify_responses_function_name(object);
185 }
186}
187
188fn qualify_responses_function_name(object: &mut Map<String, Value>) {
189 let namespace = object
190 .remove("namespace")
191 .and_then(|value| value.as_str().map(ToOwned::to_owned));
192 let Some(namespace) = namespace.as_deref() else {
193 return;
194 };
195 qualify_responses_function_name_with_namespace(object, namespace);
196}
197
198fn qualify_responses_function_name_with_namespace(
199 object: &mut Map<String, Value>,
200 namespace: &str,
201) {
202 if namespace.is_empty() {
203 return;
204 }
205 let Some(name) = object.get("name").and_then(Value::as_str) else {
206 return;
207 };
208 let prefix = format!("{namespace}{CODEX_NAMESPACE_SEPARATOR}");
209 if name.starts_with(&prefix) {
210 return;
211 }
212 object.insert("name".to_string(), Value::String(format!("{prefix}{name}")));
213}
214
215fn ensure_responses_function_tool_description(object: &mut Map<String, Value>) {
216 if object.get("type").and_then(Value::as_str) == Some("function")
217 && !matches!(object.get("description"), Some(Value::String(_)))
218 {
219 object.insert("description".to_string(), Value::String(String::new()));
220 }
221}