Repository navigation
tool choice policy is dropped on the Chat Completions path #5211
Description
Client or integration
Codex CLI
Provider or upstream service
openai-compatible
OpenCodex version
2.59.0 / commit 134c92a01b120162f00c7275189cc47858720379
Endpoint or capability
tool calls
Current behaviour
Two ways a caller restricts tool use on /v1/chat/completions do not reach the upstream.
| # | caller sent | forwarded | effect |
|---|---|---|---|
| 1 | tool_choice with type: "allowed_tools" |
no tool choice, full tool list | the model may call a tool the caller excluded |
| 2 | parallel_tool_calls: false, provider metadata unset |
no parallel_tool_calls |
the upstream may emit concurrent tool calls |
Case 1. src/chat/inbound.ts:204 handles the string forms and the function object form and has no branch for allowed_tools:
function toolChoiceToResponses(choice: unknown, body: Rec): void {
if (choice === undefined || choice === null) return;
if (choice === "auto" || choice === "none" || choice === "required") {
body.tool_choice = choice;
return;
}
if (!isRec(choice)) return;
if (choice.type === "function") {
...
body.tool_choice = { type: "function", name };
return;
}
if (isRec(choice.function) && typeof choice.function.name === "string") {
body.tool_choice = { type: "function", name: choice.function.name };
}
}An object with type: "allowed_tools" is a record, is not type: "function", and has no function member, so it reaches the end of the function and body.tool_choice is never assigned. The identifier does not occur in the file:
grep -c "allowed_tools" src/chat/inbound.ts
0
Case 2 is a narrow branch, and it is not the case covered by #1650. That issue concerned a provider configured with parallelToolCalls: false, and #1662 resolved it by adding pinParallelToolCallsFalse. This is the branch where the provider sets no value at all and the
constraint comes from the caller's own request. src/adapters/openai-chat.ts:251:
if (tools) {
if (provider.parallelToolCalls === false) {
// NIM documents the Boolean defaulting to false and kimi rejects true; pin the
// wire bit so Codex cannot opt in via request.options. ...
if (provider.baseUrl === "https://integrate.api.nvidia.com/v1"
|| provider.pinParallelToolCallsFalse === true) {
body.parallel_tool_calls = false;
}
} else if (provider.parallelToolCalls === true) {
body.parallel_tool_calls = parsed.options.parallelToolCalls !== false;
}
}There are three provider states and only two branches. When provider.parallelToolCalls is undefined, which is the default for a provider that never configured it, neither branch runs and the caller's explicit false is not forwarded.
The request value is parsed and available. src/responses/parser.ts:533:
if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls;The === true branch already consults it. The undefined state does not.
Both cases return a normal response, so the caller cannot tell the restriction was not applied.
Expected behaviour
Case 1. An allowed_tools choice reaches the upstream. Where the subset holds a single tool it maps onto the named choice the same function already builds. A larger subset has a direct counterpart on Chat Completions, so it can be forwarded as sent.
Case 2. When the provider expresses no preference, the caller's explicit parallel_tool_calls: false should be forwarded rather than dropped. The existing provider-level pin for NVIDIA and pinParallelToolCallsFalse stays as it is, since that concerns a provider that needs the bit forced regardless of the request.
Minimal redacted request or reproduction
Case 1:
curl http://127.0.0.1:10100/v1/chat/completions \
-H 'content-type: application/json' \
-H 'authorization: Bearer REDACTED' \
-d '{
"model": "REDACTED_PROVIDER/REDACTED_MODEL",
"messages": [{"role": "user", "content": "Call only tool_b."}],
"tool_choice": {
"type": "allowed_tools",
"allowed_tools": {"mode": "required", "tools": [{"type": "function", "function": {"name": "tool_b"}}]}
},
"tools": [
{"type": "function", "function": {"name": "tool_a", "parameters": {"type": "object", "properties": {}}}},
{"type": "function", "function": {"name": "tool_b", "parameters": {"type": "object", "properties": {}}}}
]
}'
Case 2, the same shape with `"parallel_tool_calls": false` and a provider whose configuration sets no `parallelToolCalls` value.Actual response or error
Case 1, the forwarded request carries both `tool_a` and `tool_b` and no `tool_choice` key at all.
Case 2, the forwarded request carries no `parallel_tool_calls` key.Upstream documentation
https://developers.openai.com/api/docs/guides/function-calling.md
"Allowed tools: Restrict the tool calls the model can make to a subset of ..."
"You can prevent this by setting parallel_tool_calls to false, which ensures exactly zero or one tool is called."
The constructs are $.tool_choice.allowed_tools.tools[*] and $.parallel_tool_calls on the Chat Completions request.
Suggested mapping or implementation notes
Case 1. Add a branch to toolChoiceToResponses for choice.type === "allowed_tools". A single-entry subset can reuse the existing named-choice path. For a larger subset the field can be forwarded as sent, since the target defines it.
Case 2. Make the undefined provider state fall through to the request value rather than doing nothing:
} else if (parsed.options.parallelToolCalls === false) {
body.parallel_tool_calls = false;
}That leaves both existing branches untouched and only fills the third state.
Additional context and attachments
- [Bug]: parallelToolCalls=false only sends parallel_tool_calls:false for the NVIDIA NIM baseUrl; ignored for all other opted-out providers #1650 and fix(openai-chat): let opted-out providers pin parallel_tool_calls:false #1662 concern the provider-configured branch and are not this case. The distinction matters, because omit-by-default was a deliberate outcome there. This report is about the branch where the caller, not the provider, states the constraint.
- Nothing was found covering
allowed_toolson the Chat inbound.
Checks
- I searched existing provider and compatibility issues.
- The request and response were redacted.
- The expected behaviour is based on an upstream specification or a concrete client requirement.
Activity
Ingwannu commentedon Sep 20, 2026
Confirmed on current
dev(2a2c01a7).toolChoiceToResponses()has noallowed_toolsbranch, so the caller's allowlist disappears before adapter dispatch. Separately, the OpenAI Chat adapter handles providerparallelToolCalls === falseand=== true, but its undefined/default branch drops an explicit callerparallel_tool_calls: false.This is authorization-like request policy and must not degrade to a wider tool surface silently. The fix should:
allowed_toolsstructure through the internal choice type;auto;parallel_tool_calls: falsewhen the provider has no overriding policy, while retaining the existing provider pin/disable precedence; andKeeping this open as a verified correctness and policy-preservation bug.
fix: preserve caller constraints on the request path (#5211, #5210, #…
lidge-jun commentedon Sep 20, 2026
Fixed on
devby #5271 (41b66be6d880625055adb319aef9bce75300a42b).The four losses on this path were fixed together because they share one failure mode: the request arrives carrying an explicit constraint, the proxy drops it, and the response is a normal success. A test that asserts a successful call or a 200 sees none of them. The governing rule the branch applies is to preserve on a supported path, and to refuse or apply the operator's conversion policy on an unsupported one, rather than dropping silently and reporting success.
Caller-specified
allowed_toolsandparallel_tool_calls: falsenow survive from the inbound parse to the actual outbound request.Closing on
dev. PRs here targetdev, so GitHub does not auto-close.