From 16533e6a74f82cbf9280490317b98d59352e9ada Mon Sep 17 00:00:00 2001 From: reonokiy Date: Sun, 11 Oct 2026 01:17:18 +0000 Subject: [PATCH 1/2] Fix Responses tool_choice compatibility Why LobeHub tool-backed generation sends required, which the gateway rejected before contacting a capable upstream. How Accept and preserve auto, none and required through the existing Codex serializers. Cover standard/lite conversion and public HTTP forwarding; retain rejection of unknown choices. --- docs/responses.md | 2 +- src/request.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++--- tests/gateway.rs | 24 ++++++++++++++++++++++- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/responses.md b/docs/responses.md index 5b7337f..6b9741e 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -20,7 +20,7 @@ Codex CLI can use the same `/v1` base URL as OpenAI SDK clients. Streaming reque | `stream` | Boolean, default `false`; upstream always streams | | `store` | Only `false` supported | | `tools` | Array, default `[]`; see below | -| `tool_choice` | Only `"auto"` supported | +| `tool_choice` | `"auto"` (default), `"none"`, or `"required"`; forwarded unchanged to Codex | | `parallel_tool_calls` | Boolean, default `true`; disabled for Responses Lite | | `reasoning` | Optional object; `effort` is a model-supported string, `summary` is `auto`, `concise`, `detailed` or `none`; `context` is `auto`, `current_turn` or `all_turns` | | `text` | `{ "verbosity"?: "low"\|"medium"\|"high", "format"?: object }` | diff --git a/src/request.rs b/src/request.rs index 6572a9b..a9d2a7d 100644 --- a/src/request.rs +++ b/src/request.rs @@ -125,9 +125,13 @@ impl CreateResponse { "store=true is not supported; send full input history", )); } - if self.tool_choice.as_deref().is_some_and(|v| v != "auto") { + if self + .tool_choice + .as_deref() + .is_some_and(|v| !["auto", "none", "required"].contains(&v)) + { return Err(GatewayError::invalid( - "Codex uses tool_choice=auto; other choices are not supported", + "tool_choice supports auto, none, and required", )); } if self.include.as_ref().is_some_and(|values| { @@ -275,7 +279,7 @@ impl CreateResponse { model: model.slug.clone(), input, tools, - tool_choice: "auto".into(), + tool_choice: self.tool_choice.unwrap_or_else(|| "auto".into()), parallel_tool_calls: self.parallel_tool_calls.unwrap_or(true) && !lite, reasoning: Some(reasoning), store: false, @@ -358,6 +362,45 @@ fn normalize_input(value: Value) -> Result, GatewayError> { mod tests { use super::*; + #[test] + fn tool_choice_is_preserved_for_standard_and_lite_requests() { + for lite in [false, true] { + let mut model = codex_models_manager::model_info::model_info_from_slug("test-model"); + model.use_responses_lite = lite; + for choice in [Value::Null, json!("auto"), json!("none"), json!("required")] { + let request: CreateResponse = serde_json::from_value(json!({ + "model": "test-model", "input": "Use the probe tool", + "tool_choice": choice, + "tools": [{"type": "function", "name": "probe", "parameters": {"type": "object", "properties": {}}}] + })).unwrap(); + let converted = request.into_codex(&model, "test-session").unwrap(); + assert_eq!(converted.tool_choice, choice.as_str().unwrap_or("auto")); + if lite { + assert!(converted.tools.is_none()); + assert!(matches!( + converted.input.first(), + Some(ResponseItem::AdditionalTools { .. }) + )); + } else { + assert_eq!( + serde_json::to_value(converted.tools).unwrap()[0]["name"], + "probe" + ); + } + } + } + } + + #[test] + fn unknown_tool_choice_is_rejected() { + let request: CreateResponse = serde_json::from_value(json!({ + "model": "test-model", "input": "Hello", "tool_choice": "invalid" + })) + .unwrap(); + let model = codex_models_manager::model_info::model_info_from_slug("test-model"); + assert!(request.into_codex(&model, "test-session").is_err()); + } + fn schema_request() -> Value { json!({ "model": "test-model", "input": "Extract memories", diff --git a/tests/gateway.rs b/tests/gateway.rs index 32f9132..e77aa18 100644 --- a/tests/gateway.rs +++ b/tests/gateway.rs @@ -612,6 +612,28 @@ async fn rejects_invalid_safety_identifier_before_contacting_upstream() { assert!(h.fake.received.lock().unwrap().is_empty()); } +#[tokio::test] +async fn public_tool_choice_is_forwarded_without_downgrading_required() { + let h = Harness::new(events(), StatusCode::OK, false, Duration::from_secs(10)).await; + for choice in ["auto", "none", "required"] { + let response = h + .request(json!({ + "input": "Use the probe tool", "tool_choice": choice, + "tools": [{"type": "function", "name": "probe", "parameters": {"type": "object", "properties": {}}}] + })) + .send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json::().await.unwrap()["status"], + "completed" + ); + let received = h.fake.received.lock().unwrap(); + let request = &received.last().unwrap().1; + assert_eq!(request["tool_choice"], choice); + assert_eq!(request["tools"][0]["name"], "probe"); + } +} + #[tokio::test] async fn rejects_unsupported_features_before_contacting_upstream() { let h = Harness::new(events(), StatusCode::OK, false, Duration::from_secs(10)).await; @@ -619,7 +641,7 @@ async fn rejects_unsupported_features_before_contacting_upstream() { json!({"input":"hi","store":true}), json!({"input":"hi","previous_response_id":"resp_1"}), json!({"input":"hi","temperature":0.5}), - json!({"input":"hi","tool_choice":"none"}), + json!({"input":"hi","tool_choice":"invalid"}), json!({"input":[{"type":"unknown"}]}), ] { let response = h.request(body).send().await.unwrap(); From 18d842c32f986f8e590b91572d7f729eaf3c222e Mon Sep 17 00:00:00 2001 From: reonokiy Date: Sun, 11 Oct 2026 01:18:31 +0000 Subject: [PATCH 2/2] Test supported tool choices through official SDK Why: The SDK compatibility suite still expected required to be rejected. How: Cover auto, none and required with function tools and reserve the rejection case for an unknown choice. --- tests/sdk/test_compatibility.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/sdk/test_compatibility.py b/tests/sdk/test_compatibility.py index 47c9cb1..3f482f9 100644 --- a/tests/sdk/test_compatibility.py +++ b/tests/sdk/test_compatibility.py @@ -101,6 +101,14 @@ def test_request_controls(client, model, request_kwargs): service_tier='auto', include=['reasoning.encrypted_content']))) +@pytest.mark.parametrize('choice', ['auto', 'none', 'required']) +def test_tool_choice(client, model, request_kwargs, choice): + completed(client.responses.create(**request_kwargs(model, + tool_choice=choice, + tools=[{'type': 'function', 'name': 'probe', + 'parameters': {'type': 'object', 'properties': {}}}]))) + + def test_raw_response(client, model, request_kwargs): raw = client.responses.with_raw_response.create(**request_kwargs(model)) assert raw.status_code == 200 @@ -197,7 +205,7 @@ def test_images_edit(client, config): pytest.param({'max_output_tokens': 0}, id='max-output-tokens-zero'), pytest.param({'previous_response_id': 'resp_1'}, id='previous-response-id'), pytest.param({'background': True}, id='background'), - pytest.param({'tool_choice': 'required'}, id='tool-choice'), + pytest.param({'tool_choice': 'invalid'}, id='tool-choice'), pytest.param({'text': {'format': {'type': 'json_object'}}}, id='json-object'), ]) def test_unsupported_values(client, request_kwargs, params):