Skip to main content

switchyard_llm_client/
responses_compatibility.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! This module applies explicitly configured Responses tool adaptations.
5
6use serde::Deserialize;
7use serde_json::{Map, Value};
8use std::collections::HashMap;
9
10/// This policy selects the Responses request changes required by a configured provider.
11#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
12#[serde(rename_all = "snake_case")]
13pub enum ResponsesCompatibility {
14    /// This mode leaves native item order, IDs, fields, and tool namespaces unchanged.
15    #[default]
16    Native,
17    /// This mode joins function namespaces and names with `__`.
18    FlatTools,
19    /// This mode flattens function namespaces, pairs outputs with calls, and adds missing names.
20    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
35// Kimi's translator needs output adjacency and names in addition to `call_id`.
36// Pairing stops at intervening messages, and existing output names remain unchanged.
37fn 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
107// The `flat_tools` and `kimi` modes flatten function namespaces in history, tools, and tool choice.
108// These modes also supply empty descriptions when function definitions lack string descriptions.
109fn 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}