From 1915506e3d2b7815487d44685f50b1e97a5a4216 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 2 Aug 2026 17:49:46 +0300 Subject: [PATCH] preserve OpenAI parallel tool-call policy OpenAI-compatible requests accepted serial tool execution without carrying that constraint into provider translation, and rejected the standard parallel value. This could silently reverse caller intent on non-Codex provider routes. Encode the OpenAI setting in the established Anthropic tool-choice policy and share its interpretation across Codex, Kimi, and Grok translators. Kimi and Grok propagate the resolved value to their upstream APIs, while native Codex Responses retains its passthrough behavior. Cover both OpenAI routes, tool-choice modes, provider wire requests, validation, and native Codex passthrough. Document accepted values and precedence. --- docs/src/content/docs/reference/http-api.md | 12 +- src/openai_compat/request.rs | 98 +++++++++++-- src/providers/codex/native.rs | 13 ++ src/providers/codex/translate/request.rs | 14 +- src/providers/grok/translate/request.rs | 31 +++- src/providers/kimi/translate/request.rs | 5 +- src/providers/translate_shared.rs | 9 ++ tests/server.rs | 148 +++++++++++++++++++- tests/smoke_cutover.rs | 52 ++++++- 9 files changed, 350 insertions(+), 32 deletions(-) diff --git a/docs/src/content/docs/reference/http-api.md b/docs/src/content/docs/reference/http-api.md index 3b25b609..51a855d3 100644 --- a/docs/src/content/docs/reference/http-api.md +++ b/docs/src/content/docs/reference/http-api.md @@ -76,7 +76,7 @@ For Kimi, Grok, and Cursor, the route accepts: - `system`, `developer`, `user`, `assistant`, and `tool` messages - text, supported user images, function calls, and tool results -- function tools and `tool_choice` +- function tools, `tool_choice`, and `parallel_tool_calls` - `max_tokens` or `max_completion_tokens` - `reasoning_effort` - streaming, non-streaming responses, and `stream_options.include_usage` @@ -128,7 +128,7 @@ Codex models use native Responses passthrough, including native JSON and SSE out - string input or message items - `instructions` -- function calls, function-call outputs, tools, and `tool_choice` +- function calls, function-call outputs, tools, `tool_choice`, and `parallel_tool_calls` - `max_output_tokens` - `reasoning.effort` - streaming or non-streaming output @@ -137,6 +137,14 @@ Responses include the accepted tool settings. Grok search appears as a `web_sear `store: true` and other unsupported non-null fields return an error. Stored response retrieval, deletion, and WebSocket client connections are not supported. +### Parallel tool calls + +The shared Kimi, Grok, and Cursor ingress accepts boolean `parallel_tool_calls` on both OpenAI routes. `false` preserves serial tool execution through translation, while `true` selects the existing parallel default. Omitting the field leaves the provider default unchanged. Non-boolean values return an `invalid_request_error` with `parallel_tool_calls` in `error.param`. + +The setting applies without changing the requested `tool_choice` mode. This includes omitted or `auto` choices, `none`, `required`, and named functions. Internally, an explicit OpenAI setting determines the equivalent Anthropic `tool_choice.disable_parallel_tool_use` value. Anthropic Messages requests can set `disable_parallel_tool_use` directly. Kimi and Grok receive the corresponding upstream `parallel_tool_calls` value, and Cursor's bridged tool loop remains serial between client tool results. + +Codex Responses uses native passthrough and forwards `parallel_tool_calls` unchanged. Codex Chat Completions has its own field allowlist and rejects `parallel_tool_calls` because that path does not support function tools. + ## OpenAI routing, sessions, and errors Both OpenAI routes strip a trailing `[1m]`, resolve configured aliases, and choose the provider from `model`. Aliases follow `aliasProvider`, while explicit provider model IDs keep their provider. Unknown models return HTTP 400 with the supported model list. diff --git a/src/openai_compat/request.rs b/src/openai_compat/request.rs index 659651b6..c467d66e 100644 --- a/src/openai_compat/request.rs +++ b/src/openai_compat/request.rs @@ -114,11 +114,12 @@ pub fn parse_request( )); } validate_single_choice(object)?; - validate_parallel_tools(object)?; + let parallel_tool_calls = optional_bool(object, "parallel_tool_calls")?; validate_store(surface, object)?; let max_tokens = parse_max_tokens(surface, object)?; let tools = parse_tools(object.get("tools"), surface)?; - let tool_choice = parse_tool_choice(object.get("tool_choice"), &tools, surface)?; + let mut tool_choice = parse_tool_choice(object.get("tool_choice"), &tools, surface)?; + apply_parallel_tool_calls(&mut tool_choice, parallel_tool_calls); let response_metadata = if surface == OpenAiSurface::Responses { OpenAiResponseMetadata { tools: object @@ -240,15 +241,18 @@ fn validate_single_choice(object: &Map) -> Result<(), OpenAiError } } -fn validate_parallel_tools(object: &Map) -> Result<(), OpenAiError> { - match object.get("parallel_tool_calls") { - None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(()), - Some(Value::Bool(true)) => Err(OpenAiError::unsupported("parallel_tool_calls")), - Some(_) => Err(OpenAiError::invalid( - "'parallel_tool_calls' must be a boolean", - Some("parallel_tool_calls"), - )), - } +fn apply_parallel_tool_calls(tool_choice: &mut Option, parallel_tool_calls: Option) { + let Some(parallel_tool_calls) = parallel_tool_calls else { + return; + }; + let choice = tool_choice.get_or_insert_with(|| json!({"type":"auto"})); + choice + .as_object_mut() + .expect("translated tool choice is an object") + .insert( + "disable_parallel_tool_use".to_string(), + Value::Bool(!parallel_tool_calls), + ); } fn parse_max_tokens( @@ -1135,6 +1139,78 @@ mod tests { assert_eq!(parsed.messages.extra["tool_choice"]["name"], "lookup"); } + #[test] + fn parallel_tool_calls_sets_anthropic_tool_choice_policy() { + let cases = [ + (None, "auto"), + (Some(json!("auto")), "auto"), + (Some(json!("none")), "none"), + (Some(json!("required")), "any"), + ( + Some(json!({"type":"function","function":{"name":"lookup"}})), + "tool", + ), + ]; + for parallel in [false, true] { + for (choice, expected_type) in &cases { + let mut body = json!({ + "model":"kimi-k2.6", + "messages":[{"role":"user","content":"look up x"}], + "tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}], + "parallel_tool_calls":parallel, + }); + if let Some(choice) = choice { + body["tool_choice"] = choice.clone(); + } + let parsed = parse_request( + OpenAiSurface::ChatCompletions, + body, + "kimi", + Some("session"), + ) + .unwrap(); + let translated = &parsed.messages.extra["tool_choice"]; + assert_eq!(translated["type"], *expected_type); + assert_eq!(translated["disable_parallel_tool_use"], !parallel); + } + } + } + + #[test] + fn responses_parallel_tool_calls_supports_named_choice() { + let parsed = parse_request( + OpenAiSurface::Responses, + json!({ + "model":"grok-4.5", + "input":"look up x", + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}], + "tool_choice":{"type":"function","name":"lookup"}, + "parallel_tool_calls":false, + }), + "grok", + None, + ) + .unwrap(); + assert_eq!(parsed.messages.extra["tool_choice"]["type"], "tool"); + assert_eq!( + parsed.messages.extra["tool_choice"]["disable_parallel_tool_use"], + true + ); + } + + #[test] + fn parallel_tool_calls_must_be_boolean() { + let error = parse_request( + OpenAiSurface::Responses, + json!({"model":"grok-4.5","input":"hello","parallel_tool_calls":"false"}), + "grok", + None, + ) + .unwrap_err(); + assert_eq!(error.param.as_deref(), Some("parallel_tool_calls")); + assert!(error.code.is_none()); + } + #[test] fn responses_maps_function_items() { let parsed = parse_request( diff --git a/src/providers/codex/native.rs b/src/providers/codex/native.rs index d3924225..63995b98 100644 --- a/src/providers/codex/native.rs +++ b/src/providers/codex/native.rs @@ -703,6 +703,19 @@ mod tests { assert_eq!(fast["service_tier"], "priority"); } + #[test] + fn native_request_preserves_parallel_tool_calls() { + for parallel in [false, true] { + let mut body = request(json!({ + "model":"gpt-5.4", + "input":[], + "parallel_tool_calls":parallel + })); + shape_native_request(&mut body).unwrap(); + assert_eq!(body["parallel_tool_calls"], parallel); + } + } + #[test] fn explicit_service_tier_is_preserved() { let mut body = request(json!({ diff --git a/src/providers/codex/translate/request.rs b/src/providers/codex/translate/request.rs index b9980397..ab1ab9bd 100644 --- a/src/providers/codex/translate/request.rs +++ b/src/providers/codex/translate/request.rs @@ -7,7 +7,8 @@ use serde_json::Value; use crate::anthropic::schema::MessagesRequest; use crate::config; use crate::providers::translate_shared::{ - ContentBlock, flatten_system_text, image_source_to_url, normalize_content, read_effort, + ContentBlock, flatten_system_text, image_source_to_url, normalize_content, parallel_tool_calls, + read_effort, }; use super::read_rewrite::{ReadOffsetRewrite, read_offset_rewrite}; @@ -433,7 +434,7 @@ pub fn translate_request( let input = build_input(req); let tools = read_tools(req)?; let tool_choice = map_tool_choice(req)?; - let parallel_tool_calls = !disable_parallel_tool_use(req); + let parallel_tool_calls = parallel_tool_calls(req).unwrap_or(true); let mut text = ResponsesText { verbosity: Some("low".to_string()), @@ -754,15 +755,6 @@ fn map_tool_choice(req: &MessagesRequest) -> Result, } } -fn disable_parallel_tool_use(req: &MessagesRequest) -> bool { - req.extra - .get("tool_choice") - .and_then(Value::as_object) - .and_then(|choice| choice.get("disable_parallel_tool_use")) - .and_then(Value::as_bool) - .unwrap_or(false) -} - fn build_input(req: &MessagesRequest) -> Vec { let mut out: Vec = Vec::new(); let mut read_tool_uses_with_offset = HashSet::new(); diff --git a/src/providers/grok/translate/request.rs b/src/providers/grok/translate/request.rs index 15c4ac2e..328ebd37 100644 --- a/src/providers/grok/translate/request.rs +++ b/src/providers/grok/translate/request.rs @@ -5,7 +5,7 @@ use serde_json::Value; use crate::anthropic::schema::{Message, MessagesRequest}; use crate::config::GrokToolImageMode; -use crate::providers::translate_shared::{ImageSource, image_source_to_url}; +use crate::providers::translate_shared::{ImageSource, image_source_to_url, parallel_tool_calls}; #[derive(Debug, Clone, Serialize)] pub struct GrokResponsesRequest { @@ -17,6 +17,8 @@ pub struct GrokResponsesRequest { pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, pub store: bool, pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -183,6 +185,7 @@ pub fn translate_request_with_mode( input, tools, tool_choice, + parallel_tool_calls: parallel_tool_calls(req), store: false, stream: true, max_output_tokens: req.max_tokens, @@ -479,11 +482,29 @@ fn parse_tool_choice( .get("type") .and_then(Value::as_str) .ok_or_else(|| anyhow::anyhow!("tool_choice type is invalid"))?; + let valid_policy = obj + .get("disable_parallel_tool_use") + .is_none_or(Value::is_boolean); match kind { - "auto" if obj.len() == 1 => Ok(Some(GrokToolChoice::Auto("auto".into()))), - "any" if obj.len() == 1 => Ok(Some(GrokToolChoice::Required("required".into()))), - "none" if obj.len() == 1 => Ok(Some(GrokToolChoice::None("none".into()))), - "tool" if obj.len() == 2 => { + "auto" | "any" | "none" + if valid_policy + && obj + .keys() + .all(|key| ["type", "disable_parallel_tool_use"].contains(&key.as_str())) => + { + Ok(Some(match kind { + "auto" => GrokToolChoice::Auto("auto".into()), + "any" => GrokToolChoice::Required("required".into()), + "none" => GrokToolChoice::None("none".into()), + _ => unreachable!(), + })) + } + "tool" + if valid_policy + && obj.keys().all(|key| { + ["type", "name", "disable_parallel_tool_use"].contains(&key.as_str()) + }) => + { let name = obj .get("name") .and_then(Value::as_str) diff --git a/src/providers/kimi/translate/request.rs b/src/providers/kimi/translate/request.rs index 0d676c67..229baff6 100644 --- a/src/providers/kimi/translate/request.rs +++ b/src/providers/kimi/translate/request.rs @@ -5,7 +5,7 @@ use super::model_allowlist::{KIMI_DEFAULT_MODEL, assert_allowed_model, is_k3, re use crate::anthropic::schema::MessagesRequest; use crate::providers::translate_shared::{ ContentBlock, flatten_system_text, image_block_to_url, image_source_to_url, normalize_content, - read_effort, + parallel_tool_calls, read_effort, }; // --------------------------------------------------------------------------- @@ -20,6 +20,8 @@ pub struct KimiChatRequest { pub tools: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_choice: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, pub stream: bool, pub stream_options: KimiStreamOptions, pub max_tokens: u32, @@ -153,6 +155,7 @@ pub fn translate_request( }), tools: if tools.is_empty() { None } else { Some(tools) }, tool_choice, + parallel_tool_calls: parallel_tool_calls(req), prompt_cache_key: opts.session_id, }; diff --git a/src/providers/translate_shared.rs b/src/providers/translate_shared.rs index 97201f53..77acd030 100644 --- a/src/providers/translate_shared.rs +++ b/src/providers/translate_shared.rs @@ -57,6 +57,15 @@ pub fn flatten_system_text(system_val: Option<&Value>) -> Option { } } +pub fn parallel_tool_calls(req: &MessagesRequest) -> Option { + req.extra + .get("tool_choice") + .and_then(Value::as_object) + .and_then(|choice| choice.get("disable_parallel_tool_use")) + .and_then(Value::as_bool) + .map(|disabled| !disabled) +} + pub fn read_effort(req: &MessagesRequest) -> Result, anyhow::Error> { let output_config = match req.extra.get("output_config") { Some(Value::Object(m)) => m, diff --git a/tests/server.rs b/tests/server.rs index 9087afff..f71f9c7f 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -14,7 +14,7 @@ use claude_code_proxy::{ }, }; use serde_json::{Value, json}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use tower::util::ServiceExt; fn body_string(json: &str) -> Body { @@ -95,6 +95,99 @@ impl Provider for FakeProvider { } } +struct TranslatingProvider { + name: &'static str, + model: &'static str, + captured: Arc>>, +} + +#[async_trait] +impl Provider for TranslatingProvider { + fn name(&self) -> &'static str { + self.name + } + + fn supported_models(&self) -> Vec { + vec![self.model.to_string()] + } + + fn cli(&self) -> &'static dyn CliHandlers { + &FAKE_CLI + } + + async fn handle_messages( + &self, + _body: MessagesRequest, + _ctx: RequestContext, + ) -> axum::response::Response { + (StatusCode::NOT_IMPLEMENTED, "unused").into_response() + } + + async fn handle_count_tokens( + &self, + _body: MessagesRequest, + _ctx: RequestContext, + ) -> axum::response::Response { + (StatusCode::NOT_IMPLEMENTED, "unused").into_response() + } + + async fn generate_anthropic_stream( + &self, + body: MessagesRequest, + _ctx: RequestContext, + ) -> Result { + let translated = match self.name { + "kimi" => serde_json::to_value( + claude_code_proxy::providers::kimi::translate::request::translate_request( + &body, + claude_code_proxy::providers::kimi::translate::request::TranslateOptions { + session_id: None, + }, + ) + .unwrap(), + ) + .unwrap(), + "grok" => serde_json::to_value( + claude_code_proxy::providers::grok::translate::request::translate_request( + &body, + self.model.to_string(), + ) + .unwrap(), + ) + .unwrap(), + _ => unreachable!(), + }; + *self.captured.lock().unwrap() = Some(translated); + let sse = concat!( + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_fake\",\"model\":\"test\",\"usage\":{\"input_tokens\":1}}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + ); + Ok(Generation { + body: GenerationBody::BufferedSse(sse.into()), + resolved_model: self.model.to_string(), + }) + } +} + +fn translating_registry( + name: &'static str, + model: &'static str, + captured: Arc>>, +) -> Arc { + Arc::new(Registry::from_providers( + AliasProvider::Kimi, + vec![Arc::new(TranslatingProvider { + name, + model, + captured, + }) as Arc], + )) +} + fn routed_registry() -> Arc { Arc::new(Registry::from_providers( AliasProvider::Kimi, @@ -756,6 +849,59 @@ async fn openai_routes_select_non_codex_providers_and_aliases() { } } +#[tokio::test] +async fn openai_routes_preserve_serial_tool_calls_upstream() { + for (provider, model, uri, body, expected_choice) in [ + ( + "kimi", + "kimi-k2.6", + "/v1/chat/completions", + json!({ + "model":"kimi-k2.6", + "messages":[{"role":"user","content":"look up x"}], + "tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}], + "tool_choice":{"type":"function","function":{"name":"lookup"}}, + "parallel_tool_calls":false + }), + json!({"type":"function","function":{"name":"lookup"}}), + ), + ( + "grok", + "grok-4.5", + "/v1/responses", + json!({ + "model":"grok-4.5", + "input":"look up x", + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}], + "tool_choice":"none", + "parallel_tool_calls":false + }), + json!("none"), + ), + ] { + let captured = Arc::new(Mutex::new(None)); + let response = app_with_options( + translating_registry(provider, model, captured.clone()), + None, + true, + ) + .oneshot( + Request::builder() + .method(Method::POST) + .uri(uri) + .header("content-type", "application/json") + .body(body_string(&body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let translated = captured.lock().unwrap().clone().unwrap(); + assert_eq!(translated["parallel_tool_calls"], false); + assert_eq!(translated["tool_choice"], expected_choice); + } +} + #[tokio::test] async fn routed_openai_streams_use_surface_specific_events() { let chat = app_with_options(routed_registry(), None, true) diff --git a/tests/smoke_cutover.rs b/tests/smoke_cutover.rs index f340cb77..7bbd6028 100644 --- a/tests/smoke_cutover.rs +++ b/tests/smoke_cutover.rs @@ -7,7 +7,10 @@ use axum::response::Response; use claude_code_proxy::providers::codex::compaction::clear_all_compactions_for_tests; use claude_code_proxy::providers::codex::continuation::clear_all_continuations_for_tests; use claude_code_proxy::providers::codex::websocket::clear_codex_websocket_pool_for_tests; -use claude_code_proxy::{registry::Registry, server::app}; +use claude_code_proxy::{ + registry::Registry, + server::{app, app_with_options}, +}; use futures_util::{SinkExt, StreamExt}; use http_body_util::BodyExt; use serde_json::{Value, json}; @@ -100,6 +103,22 @@ async fn call_messages_body(body: Value) -> Response { .unwrap() } +async fn call_responses_body(body: Value) -> Response { + let _no_proxy_env = EnvGuard::set("NO_PROXY", "127.0.0.1,localhost"); + app_with_options(Arc::new(Registry::with_default_alias()), None, true) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/responses") + .header("content-type", "application/json") + .header("x-claude-code-session-id", "smoke-session") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap() +} + fn collect_files(root: &Path) -> Vec { let mut out = Vec::new(); let Ok(entries) = std::fs::read_dir(root) else { @@ -832,6 +851,37 @@ async fn smoke_codex_http_messages_uses_mock_upstream() { assert_eq!(sent["stream"], true); } +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn smoke_codex_native_responses_preserves_parallel_tool_calls() { + let _guard = env_lock(); + let config = TempDir::new().unwrap(); + write_auth(config.path(), "codex"); + + let captured = Arc::new(Mutex::new(None)); + let upstream = spawn_http_upstream({ + let captured = captured.clone(); + move |body: Value| { + let _ = captured.lock().map(|mut guard| *guard = Some(body)); + br#"{"id":"resp_1","object":"response","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}"#.to_vec() + } + }) + .await; + + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + let _base_url_env = EnvGuard::set("CCP_CODEX_BASE_URL", &upstream); + let response = call_responses_body(json!({ + "model":"gpt-5.4", + "input":"hello", + "parallel_tool_calls":false + })) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let sent = captured.lock().unwrap().clone().unwrap(); + assert_eq!(sent["parallel_tool_calls"], false); +} + /// Resets the retry-delay override even when the test panics, so later tests /// in this process keep real backoff behavior. struct ZeroRetryDelayGuard;