diff --git a/crates/service/src/aggregate_api.rs b/crates/service/src/aggregate_api.rs index 465fc2d1b..6b5ceade5 100644 --- a/crates/service/src/aggregate_api.rs +++ b/crates/service/src/aggregate_api.rs @@ -1275,7 +1275,8 @@ fn build_codex_probe_body(model: &str) -> serde_json::Value { "text": "Who are you?" }] }], - "stream": true + "stream": true, + "store": false }) } diff --git a/crates/service/src/aggregate_api_tests.rs b/crates/service/src/aggregate_api_tests.rs index f9a61b38c..62aeba381 100644 --- a/crates/service/src/aggregate_api_tests.rs +++ b/crates/service/src/aggregate_api_tests.rs @@ -443,6 +443,7 @@ fn codex_responses_probe_uses_valid_input_text_content() { let body: Value = serde_json::from_str(captured.1.as_str()).expect("parse body"); assert_eq!(body["model"], "gpt-5.6-sol"); assert_eq!(body["input"][0]["content"][0]["type"], "input_text"); + assert_eq!(body["store"], false); } #[test] diff --git a/crates/service/src/gateway/mod.rs b/crates/service/src/gateway/mod.rs index d7507ec4f..4300c505c 100644 --- a/crates/service/src/gateway/mod.rs +++ b/crates/service/src/gateway/mod.rs @@ -165,7 +165,8 @@ pub(crate) use request_log::{ #[cfg(test)] use request_rewrite::apply_request_overrides_with_service_tier_and_prompt_cache_key; use request_rewrite::{ - apply_codex_candidate_transport_rules, apply_request_overrides_for_deferred_aggregate, + apply_codex_candidate_transport_rules, apply_external_dynamic_tools_transport_rules, + apply_request_overrides_for_deferred_aggregate, apply_request_overrides_with_service_tier_and_forced_prompt_cache_key_scope, apply_request_overrides_with_service_tier_and_prompt_cache_key_scope, compute_upstream_url, }; diff --git a/crates/service/src/gateway/request/official_responses_http.rs b/crates/service/src/gateway/request/official_responses_http.rs index fbfb88575..f51ab015b 100644 --- a/crates/service/src/gateway/request/official_responses_http.rs +++ b/crates/service/src/gateway/request/official_responses_http.rs @@ -423,7 +423,7 @@ fn normalize_codex_backend_service_tier(path: &str, obj: &mut Map normalize_service_tier(path, obj) } -fn normalize_dynamic_tools_to_tools(path: &str, obj: &mut Map) -> bool { +pub(crate) fn normalize_dynamic_tools_to_tools(path: &str, obj: &mut Map) -> bool { if !is_responses_path(path) { return false; } diff --git a/crates/service/src/gateway/request/request_rewrite.rs b/crates/service/src/gateway/request/request_rewrite.rs index 807cc54ea..f4f6fa300 100644 --- a/crates/service/src/gateway/request/request_rewrite.rs +++ b/crates/service/src/gateway/request/request_rewrite.rs @@ -571,6 +571,22 @@ pub(super) fn apply_codex_candidate_transport_rules(path: &str, body: Vec) - serde_json::to_vec(&payload).unwrap_or(body) } +pub(super) fn apply_external_dynamic_tools_transport_rules(path: &str, body: Vec) -> Vec { + if body.is_empty() || !responses::is_responses_path(path) { + return body; + } + let Ok(mut payload) = serde_json::from_slice::(&body) else { + return body; + }; + let Some(obj) = payload.as_object_mut() else { + return body; + }; + if !responses::normalize_dynamic_tools_to_tools(path, obj) { + return body; + } + serde_json::to_vec(&payload).unwrap_or(body) +} + /// 函数 `apply_request_overrides_with_prompt_cache_key_mode` /// /// 作者: gaohongshun @@ -604,6 +620,12 @@ fn apply_request_overrides_with_prompt_cache_key_mode( let use_codex_responses_compat = should_apply_codex_responses_compat(path, upstream_base_url, resolve_default_upstream_base); let use_codex_compat_rewrite = allow_codex_compat_rewrite && use_codex_responses_compat; + // Non-official Responses providers commonly reject Codex's dynamicTools + // envelope. Convert it only when an explicit non-Codex upstream is known; + // deferred routing must keep the original body until a candidate is chosen. + let normalize_external_dynamic_tools = upstream_base_url.is_some() + && responses::is_responses_path(path) + && !use_codex_responses_compat; let chat_rules_path = chat_request_rules_path(path); let normalized_model = model_slug .map(str::trim) @@ -624,6 +646,12 @@ fn apply_request_overrides_with_prompt_cache_key_mode( let mut changed = false; let mut dropped_keys = Vec::new(); + if normalize_external_dynamic_tools + && responses::normalize_dynamic_tools_to_tools(path, obj) + { + changed = true; + } + // Ultra 由 Codex 客户端负责多代理编排;单个上游请求必须使用 Max。 // 客户端原始值在进入此重写前已单独采集,供请求日志展示 ultra -> max。 if normalize_client_ultra_for_upstream(obj) { diff --git a/crates/service/src/gateway/request/tests/request_rewrite_tests.rs b/crates/service/src/gateway/request/tests/request_rewrite_tests.rs index 73407c4a5..7e76313aa 100644 --- a/crates/service/src/gateway/request/tests/request_rewrite_tests.rs +++ b/crates/service/src/gateway/request/tests/request_rewrite_tests.rs @@ -1382,6 +1382,39 @@ fn responses_dynamic_tools_are_mapped_to_tools_for_codex_backend() { ); } +#[test] +fn responses_dynamic_tools_are_mapped_for_explicit_non_codex_upstream() { + let _guard = crate::test_env_guard(); + let body = json!({ + "model": "muse-spark-1.2-contributor", + "input": "hello", + "dynamicTools": [{ + "name": "spawn_agent", + "description": "start a subagent", + "input_schema": { + "type": "object", + "properties": {"prompt": {"type": "string"}} + } + }] + }); + let out = apply_request_overrides( + "/v1/responses", + serde_json::to_vec(&body).expect("serialize request body"), + None, + None, + Some("https://muse.example/v1"), + ); + let value: serde_json::Value = serde_json::from_slice(&out).expect("parse output body"); + let tools = value + .get("tools") + .and_then(serde_json::Value::as_array) + .expect("tools array"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["type"], "function"); + assert_eq!(tools[0]["name"], "spawn_agent"); + assert!(value.get("dynamicTools").is_none()); +} + /// 函数 `responses_preserves_priority_service_tier_for_codex_backend` /// /// 作者: gaohongshun diff --git a/crates/service/src/gateway/upstream/protocol/aggregate_api.rs b/crates/service/src/gateway/upstream/protocol/aggregate_api.rs index 0d7c0342f..bc0601791 100644 --- a/crates/service/src/gateway/upstream/protocol/aggregate_api.rs +++ b/crates/service/src/gateway/upstream/protocol/aggregate_api.rs @@ -157,6 +157,14 @@ fn rewrite_body_for_candidate_transport( upstream_url: &str, ) -> Bytes { let rewritten = rewrite_body_model_override(body, candidate.model_override.as_deref()); + if !super::super::config::should_send_chatgpt_account_header(upstream_url) { + return Bytes::from( + super::super::super::apply_external_dynamic_tools_transport_rules( + path, + rewritten.to_vec(), + ), + ); + } if normalize_provider_type_value(candidate.provider_type.as_str()) == AGGREGATE_API_PROVIDER_CODEX && super::super::config::should_send_chatgpt_account_header(upstream_url) @@ -1640,7 +1648,7 @@ mod bridge_tests { fn candidate_transport_rewrite_isolated_between_codex_and_generic_upstreams() { let _guard = crate::test_env_guard(); let body = Bytes::from_static( - br#"{"model":"platform-model","input":"hello","stream":false,"service_tier":"fast"}"#, + br#"{"model":"platform-model","input":"hello","stream":false,"service_tier":"fast","dynamicTools":[{"name":"spawn_agent"}]}"#, ); let mut codex = candidate("codex", 0); codex.model_override = Some("gpt-5.4".to_string()); @@ -1696,6 +1704,9 @@ mod bridge_tests { assert_eq!(generic_value["input"], "hello"); assert_eq!(generic_value["stream"], false); assert_eq!(generic_value["service_tier"], "fast"); + assert_eq!(generic_value["tools"][0]["type"], "function"); + assert_eq!(generic_value["tools"][0]["name"], "spawn_agent"); + assert!(generic_value.get("dynamicTools").is_none()); assert!(generic_value.get("instructions").is_none()); assert!(generic_value.get("store").is_none()); assert!(generic_value.get("tool_choice").is_none());