Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand Down
49 changes: 46 additions & 3 deletions src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the SDK suite for the newly accepted tool choice

When the sdk job in .github/workflows/image.yml runs cargo test --locked --test gateway actual_ -- --ignored, actual_openai_sdk_compatibility_suite executes test_compatibility.py::test_unsupported_values[tool-choice], which still expects tool_choice='required' to raise a 400/422 error. This allowlist now forwards that request to the successful fake upstream, so the expected exception is not raised and the required CI job fails deterministically; move this case to an accepted-value test and update the compatibility matrix accordingly.

Useful? React with 👍 / 👎.

{
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| {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -358,6 +362,45 @@ fn normalize_input(value: Value) -> Result<Vec<ResponseItem>, 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",
Expand Down
24 changes: 23 additions & 1 deletion tests/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,14 +612,36 @@ 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::<Value>().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;
for body in [
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();
Expand Down
10 changes: 9 additions & 1 deletion tests/sdk/test_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading