Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src-tauri/src/proxy/providers/codex_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use super::{
CodexAdapter, ProviderAdapter,
};

pub(crate) const CODEX_REQUEST_PREPARER_VERSION: u32 = 7;
pub(crate) const CODEX_REQUEST_PREPARER_VERSION: u32 = 8;
const DEFAULT_THIRD_PARTY_USER_AGENT: &str = concat!("CCSwitchMulti/", env!("CARGO_PKG_VERSION"));

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -357,6 +357,9 @@ impl CodexThirdPartyRequestPolicy {

fn prepare_responses_body(&self, mut logical_body: Value) -> Result<Value, ProxyError> {
prepare_codex_native_responses_model(&self.provider, &mut logical_body)?;
if super::codex_responses_tool_history::needs_namespace_consolidation(&self.base_url) {
super::codex_responses_tool_history::consolidate_namespaces(&mut logical_body);
}
Ok(logical_body)
}
}
Expand Down
90 changes: 90 additions & 0 deletions src-tauri/src/proxy/providers/codex_request_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,96 @@ fn logical_request() -> Value {
})
}

fn repeated_namespace_request() -> Value {
let function = |name: &str| {
json!({"type":"function", "name":name,
"parameters":{"type":"object", "properties":{}}, "strict":false})
};
let mut body = logical_request();
body["input"] = json!([
{"type":"tool_search_call", "call_id":"search_1", "status":"completed",
"execution":"client", "arguments":{"query":"first"}},
{"type":"tool_search_output", "call_id":"search_1", "status":"completed",
"execution":"client", "tools":[{"type":"namespace", "name":"mcp__hindsight",
"description":"Memory tools", "tools":[function("search")]}]},
{"type":"tool_search_call", "call_id":"search_2", "status":"completed",
"execution":"client", "arguments":{"query":"second"}},
{"type":"tool_search_output", "call_id":"search_2", "status":"completed",
"execution":"client", "tools":[{"type":"namespace", "name":"mcp__hindsight",
"description":"Memory tools", "tools":[function("search"), function("read")]}]},
{"role":"user", "content":"Reply OK"}
]);
body
}

#[test]
fn namespace_consolidation_is_shared_by_runtime_and_probe_preparation() {
for base_url in [
"https://opencode.ai/zen/go/v1",
"https://api.deepseek.com/v1",
] {
let mut provider = third_party_provider("openai_responses");
provider.settings_config["base_url"] = json!(base_url);
let policy = CodexThirdPartyRequestPolicy::compile(&provider).unwrap();
let options = CodexRequestOptions::default();
let logical = repeated_namespace_request();
let runtime_protocol = policy
.prepare_protocol_body(CodexRequestTransport::Responses, logical.clone(), &options)
.unwrap();
assert_eq!(
runtime_protocol["input"][1]["tools"][0]["tools"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(runtime_protocol["input"][3]["tools"], json!([]));
assert_eq!(runtime_protocol["input"][3]["call_id"], "search_2");
assert_eq!(runtime_protocol["input"].as_array().unwrap().len(), 5);
let runtime = policy
.finalize_body(CodexRequestTransport::Responses, runtime_protocol, &options)
.unwrap();
let probe = policy
.prepare(CodexRequestTransport::Responses, logical, options)
.unwrap();
assert_eq!(runtime, probe.body);
}
}

#[test]
fn namespace_consolidation_does_not_change_other_native_gateways() {
let policy =
CodexThirdPartyRequestPolicy::compile(&third_party_provider("openai_responses")).unwrap();
let logical = repeated_namespace_request();
let prepared = policy
.prepare_protocol_body(
CodexRequestTransport::Responses,
logical.clone(),
&CodexRequestOptions::default(),
)
.unwrap();
assert_eq!(prepared["input"], logical["input"]);
}

#[test]
fn namespace_consolidation_does_not_change_chat_conversion() {
let provider = third_party_provider("openai_chat");
let mut go = provider.clone();
go.settings_config["base_url"] = json!("https://opencode.ai/zen/go/v1");
let options = CodexRequestOptions::default();
let prepare = |provider: &Provider| {
CodexThirdPartyRequestPolicy::compile(provider)
.unwrap()
.prepare_protocol_body(
CodexRequestTransport::ChatCompletions,
repeated_namespace_request(),
&options,
)
.unwrap()
};
assert_eq!(prepare(&provider), prepare(&go));
}

fn realistic_codex_tool_request() -> Value {
json!({
"model": "visible-model",
Expand Down
247 changes: 247 additions & 0 deletions src-tauri/src/proxy/providers/codex_responses_tool_history.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
//! Compatibility for gateways that register discovery namespaces request-wide.
//! Keep discovery call/output pairs and qualified function names intact.

use std::collections::{HashMap, HashSet};

use serde_json::Value;
use url::Url;

pub(super) fn needs_namespace_consolidation(base_url: &str) -> bool {
let Ok(url) = Url::parse(base_url) else {
return false;
};
match url.host_str() {
Some("api.deepseek.com") => true,
Some("opencode.ai") => {
let path = url.path().trim_end_matches('/');
path == "/zen/go/v1" || path == "/zen/go/v1/responses"
}
_ => false,
}
}

// Only inspect protocol tool lists, never arbitrary JSON in arguments or output.
fn visit_tool_lists(body: &mut Value, mut visit: impl FnMut(&mut Vec<Value>)) {
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
visit(tools);
}
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
for item in input {
if item.get("type").and_then(Value::as_str) == Some("tool_search_output") {
if let Some(tools) = item.get_mut("tools").and_then(Value::as_array_mut) {
visit(tools);
}
}
}
}
}

fn namespace_name(tool: &Value) -> Option<&str> {
(tool.get("type")?.as_str()? == "namespace")
.then(|| tool.get("name")?.as_str())?
.filter(|name| !name.is_empty())
}

fn function_name(tool: &Value) -> Option<&str> {
(tool.get("type")?.as_str()? == "function")
.then(|| tool.get("name")?.as_str())?
.filter(|name| !name.is_empty())
}

fn merge_namespace(merged: &mut Value, incoming: &Value) -> Option<()> {
let mut envelope = incoming.clone();
let incoming_tools = envelope.as_object_mut()?.remove("tools")?;
let mut previous_envelope = merged.clone();
previous_envelope.as_object_mut()?.remove("tools");
if envelope != previous_envelope {
return None;
}
let tools = merged.get_mut("tools")?.as_array_mut()?;
for tool in incoming_tools.as_array()? {
let name = function_name(tool)?;
if let Some(existing) = tools
.iter_mut()
.find(|entry| function_name(entry) == Some(name))
{
let mut previous_definition = existing.clone();
let mut incoming_definition = tool.clone();
previous_definition.as_object_mut()?.remove("description");
incoming_definition.as_object_mut()?.remove("description");
if previous_definition != incoming_definition {
return None;
}
// Plugin attribution can change between discoveries without changing
// the callable contract. Keep the latest description in that case.
*existing = tool.clone();
} else {
tools.push(tool.clone());
}
}
Some(())
}

pub(super) fn consolidate_namespaces(body: &mut Value) {
let mut groups: HashMap<String, (usize, Option<Value>)> = HashMap::new();
visit_tool_lists(body, |tools| {
for tool in tools.iter() {
let Some(name) = namespace_name(tool) else {
continue;
};
let entry = groups.entry(name.to_owned()).or_insert_with(|| {
let mut empty = tool.clone();
empty["tools"] = Value::Array(Vec::new());
(0, Some(empty))
});
entry.0 += 1;
if let Some(merged) = entry.1.as_mut() {
if merge_namespace(merged, tool).is_none() {
// Do not guess when schemas, metadata, or tool kinds conflict.
// Leave this entire namespace untouched for upstream validation.
entry.1 = None;
}
}
}
});
groups.retain(|_, (count, merged)| *count > 1 && merged.is_some());
if groups.is_empty() {
return;
}
let mut emitted = HashSet::new();
visit_tool_lists(body, |tools| {
tools.retain_mut(|tool| {
let Some(name) = namespace_name(tool).map(str::to_owned) else {
return true;
};
let Some((_, Some(merged))) = groups.get(&name) else {
return true;
};
if !emitted.insert(name) {
return false;
}
*tool = merged.clone();
true
});
});
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

fn namespace(name: &str, names: &[&str]) -> Value {
json!({"type":"namespace", "name":name, "description":"Test tools", "tools":
names.iter().map(|name| json!({"type":"function", "name":name,
"parameters":{"type":"object", "properties":{}}, "strict":false})).collect::<Vec<_>>()})
}

fn output(id: &str, tools: Vec<Value>) -> Value {
json!({"type":"tool_search_output", "id":format!("out_{id}"),
"call_id":id, "status":"completed", "execution":"client", "tools":tools})
}

#[test]
fn repeated_discoveries_union_tools_without_changing_history_links() {
let mut body = json!({"input":[
{"type":"tool_search_call", "call_id":"a", "arguments":{"query":"first"}},
output("a", vec![namespace("mcp__hindsight", &["search"])]),
{"type":"function_call", "namespace":"mcp__hindsight", "name":"search", "call_id":"f", "arguments":"{}"},
{"type":"function_call_output", "call_id":"f", "output":"result"},
{"type":"tool_search_call", "call_id":"b", "arguments":{"query":"second"}},
output("b", vec![namespace("mcp__hindsight", &["search", "read"])])
]});
let mut expected = body.clone();
expected["input"][1]["tools"][0] = namespace("mcp__hindsight", &["search", "read"]);
expected["input"][5]["tools"] = json!([]);
consolidate_namespaces(&mut body);
assert_eq!(body, expected);
consolidate_namespaces(&mut body);
assert_eq!(body, expected, "normalization must be idempotent");
}

#[test]
fn includes_top_level_tools_and_keeps_unrelated_entries_in_order() {
let plain = json!({"type":"function", "name":"plain"});
let other = namespace("other", &["search"]);
let mut body = json!({"tools":[namespace("mcp__websearch", &["search"]), plain],
"input":[output("a", vec![namespace("mcp__websearch", &["fetch"]), other.clone()])]});
consolidate_namespaces(&mut body);
assert_eq!(
body["tools"][0],
namespace("mcp__websearch", &["search", "fetch"])
);
assert_eq!(body["tools"][1], plain);
assert_eq!(body["input"][0]["tools"], json!([other]));
}

#[test]
fn schema_or_metadata_conflicts_leave_entire_group_unchanged() {
for conflict in [
json!({"type":"namespace", "name":"n", "description":"changed", "tools":[]}),
json!({"type":"namespace", "name":"n", "description":"Test tools", "tools":[
{"type":"function", "name":"search", "parameters":{"type":"string"}}]}),
json!({"type":"namespace", "name":"n", "description":"Test tools", "tools":[
{"type":"custom", "name":"custom"}]}),
json!({"type":"namespace", "name":"n", "description":"Test tools", "tools":null}),
] {
let mut body = json!({"input":[output("a", vec![namespace("n", &["search"])]),
output("b", vec![namespace("n", &["extra"])]), output("c", vec![conflict])]});
let original = body.clone();
consolidate_namespaces(&mut body);
assert_eq!(body, original);
}
}

#[test]
fn function_description_changes_keep_latest_text_without_losing_tools() {
let mut first = namespace("mcp__codex_apps__github", &["get_repo"]);
first["tools"][0]["description"] = json!("Repository metadata. Plugins: Data, GitHub.");
let mut second = namespace("mcp__codex_apps__github", &["get_repo", "search"]);
second["tools"][0]["description"] = json!("Repository metadata. Plugin: GitHub.");
let mut body =
json!({"input":[output("a", vec![first]), output("b", vec![second.clone()])]});
consolidate_namespaces(&mut body);
assert_eq!(body["input"][0]["tools"], json!([second]));
assert_eq!(body["input"][1]["tools"], json!([]));
}

#[test]
fn unique_namespaces_and_arbitrary_nested_data_are_untouched() {
let n = namespace("n", &["search"]);
for mut body in [
json!({"input":"hello"}),
json!({"tools":[n.clone()]}),
json!({"input":[output("a", vec![n.clone()]),
{"type":"function_call_output", "output":{"tools":[n.clone()]}},
{"role":"user", "content":{"type":"tool_search_output", "tools":[n.clone()]}}]}),
json!({"tools":null, "input":[{"type":"tool_search_output", "tools":null}]}),
] {
let original = body.clone();
consolidate_namespaces(&mut body);
assert_eq!(body, original);
}
}

#[test]
fn consolidation_is_scoped_to_known_native_gateways() {
for url in [
"https://api.deepseek.com/v1",
"https://opencode.ai/zen/go/v1/",
"https://opencode.ai/zen/go/v1/responses",
] {
assert!(needs_namespace_consolidation(url), "{url}");
}
for url in [
"https://api.openai.com/v1",
"https://chatgpt.com/backend-api/codex",
"https://api.x.ai/v1",
"https://opencode.ai/zen/v1",
"https://opencode.ai/zen/go/v10",
"https://opencode.ai.example/zen/go/v1",
"https://example.com/api.deepseek.com",
"invalid",
] {
assert!(!needs_namespace_consolidation(url), "{url}");
}
}
}
1 change: 1 addition & 0 deletions src-tauri/src/proxy/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod codex_request_tests;
#[cfg(test)]
mod codex_request_user_agent_tests;
pub(crate) mod codex_responses_sse;
mod codex_responses_tool_history;
pub(crate) mod codex_terminal;
mod codex_tool_schema;
pub mod copilot_auth;
Expand Down
Loading