fix(models): preserve Codex tool_choice in requests (#6898)

* fix(models): preserve Codex tool_choice in requests

Signed-off-by: kuku <186267668+xbzz1018@users.noreply.github.com>

* fix(models): validate Codex tool selection before HTTP

Signed-off-by: kuku <186267668+xbzz1018@users.noreply.github.com>

---------

Signed-off-by: kuku <186267668+xbzz1018@users.noreply.github.com>
Co-authored-by: kuku <186267668+xbzz1018@users.noreply.github.com>
This commit is contained in:
kuku 2026-10-15 09:58:16 +08:00 committed by GitHub
parent 11979fdb57
commit a6b3552362
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 246 additions and 7 deletions

View File

@ -381,6 +381,7 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
- Codex CLI reads `~/.codex/auth.json`
- Codex function tools preserve explicit `strict: true` or `strict: false` in wrapped or flat dictionary definitions. Missing or null settings keep the provider default. `bind_tools` applies the same conversion to dictionaries and `BaseTool` schemas.
- Codex honors `tool_choice` in `bind_tools` and direct calls: `auto`, `none`, `required`, or a function name/selection. `any` and `True` mean `required`; `None` and `False` keep the provider default. Per-call choices override the bound policy. Bound choices are validated when binding; direct calls and overrides are validated before HTTP. `required` needs at least one function tool, and named selections must match a supplied function. `auto` and `none` also work without tools.
- Completed Codex responses still return their text and tool calls when token usage is null, omitted, or empty; usage metadata remains unavailable.
- The Codex model provider returns completed responses without waiting for the SSE connection to close. Failed or incomplete responses report the provider's error or reason; partial output is not returned as a successful answer. Non-object error details are reported as text.
- Claude Code accepts `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_CREDENTIALS_PATH`, or `~/.claude/.credentials.json`

View File

@ -228,6 +228,7 @@ YAML 同名模型优先,已有任务保留原配置快照。托管模型使用
```
- Codex CLI 会读取 `~/.codex/auth.json`
- Codex 的 `bind_tools` 和直接调用支持 `tool_choice`:`auto`、`none`、`required` 或指定函数名称/对象。`any` 和 `True` 表示 `required`;`None` 和 `False` 保留服务端默认策略。单次调用可覆盖绑定策略。绑定时校验绑定策略;直接调用及覆盖参数在 HTTP 请求前校验。`required` 至少需要一个函数工具,指定函数必须存在于工具列表中。无工具时仍支持 `auto` 和 `none`。
- Claude Code 支持 `CLAUDE_CODE_OAUTH_TOKEN`、`ANTHROPIC_AUTH_TOKEN`、`CLAUDE_CODE_CREDENTIALS_PATH`,或 `~/.claude/.credentials.json`
- ACP agent 条目与 model provider 是分开配置的——如果你配置了 `acp_agents.codex`,请把它指向一个 Codex ACP 适配器,例如 `npx -y @zed-industries/codex-acp`
- MiniMax Code 原生支持 ACP,不需要额外适配器。先安装并登录,再把它配置成 ACP agent:

View File

@ -109,6 +109,16 @@ share `_convert_tools` so binding cannot discard the setting. Keep caller
schemas and definitions unchanged. Offline sync/async request and tool-followup
coverage: `tests/test_codex_tool_strict.py`.
Codex forwards `tool_choice` from `bind_tools` and direct sync/async calls.
Normalize `any`/`True` to `required`, named or wrapped function selections to
Responses' flat `{type: function, name: ...}` shape, and omit `None`/`False`.
Validate bound selections at `bind_tools`; validate invocation overrides again
before HTTP. `required` needs at least one converted function tool, and a named
selection must match a converted function. `auto` and `none` remain valid without
tools. Per-call choices override bound choices without mutating caller definitions
or history. Offline HTTP serialization and tool-followup coverage:
`tests/test_codex_tool_choice.py`.
### Codex SSE termination (`packages/harness/deerflow/models/openai_codex_provider.py`)
Completed responses with null, omitted, or empty `usage` retain their text,

View File

@ -34,6 +34,31 @@ def _is_valid_call_id(call_id: Any) -> bool:
return isinstance(call_id, str) and bool(call_id.strip())
def _convert_tool_choice(tool_choice: str | dict | bool | None) -> str | dict | None:
"""Normalize LangChain function selection to the Responses API format."""
if tool_choice is None or tool_choice is False:
return None
if tool_choice is True or tool_choice == "any":
return "required"
if isinstance(tool_choice, str) and tool_choice:
if tool_choice in ("auto", "none", "required"):
return tool_choice
return {"type": "function", "name": tool_choice}
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
function = tool_choice.get("function", tool_choice)
if isinstance(function, dict) and isinstance(function.get("name"), str) and function["name"]:
return {"type": "function", "name": function["name"]}
raise ValueError("tool_choice must be auto, none, required, any, a function name/selection, a boolean, or None")
def _validate_tool_choice(tool_choice: str | dict | None, tools: list[dict]) -> None:
"""Check forced choices against the function tools that will be sent."""
if tool_choice == "required" and not tools:
raise ValueError("tool_choice='required' needs at least one function tool")
if isinstance(tool_choice, dict) and not any(tool["name"] == tool_choice["name"] for tool in tools):
raise ValueError("tool_choice must select a function present in tools")
def _build_usage_metadata(oai_usage: dict) -> dict:
"""Convert Codex/Responses API usage dict to LangChain usage_metadata format.
@ -234,7 +259,7 @@ class CodexChatModel(BaseChatModel):
responses_tools.append(converted)
return responses_tools
def _call_codex_api(self, messages: list[BaseMessage], tools: list[dict] | None = None) -> dict:
def _call_codex_api(self, messages: list[BaseMessage], tools: list[dict] | None = None, tool_choice: str | dict | bool | None = None) -> dict:
"""Call the Codex Responses API and return the completed response."""
instructions, input_items = self._convert_messages(messages)
@ -247,8 +272,13 @@ class CodexChatModel(BaseChatModel):
"reasoning": {"effort": self.reasoning_effort, "summary": "detailed"} if self.reasoning_effort != "none" else {"effort": "none"},
}
if tools:
payload["tools"] = self._convert_tools(tools)
converted_tools = self._convert_tools(tools or [])
converted_choice = _convert_tool_choice(tool_choice)
_validate_tool_choice(converted_choice, converted_tools)
if converted_tools:
payload["tools"] = converted_tools
if converted_choice is not None:
payload["tool_choice"] = converted_choice
headers = {
"Authorization": f"Bearer {self._access_token}",
@ -465,15 +495,16 @@ class CodexChatModel(BaseChatModel):
) -> ChatResult:
"""Generate a response using Codex Responses API."""
tools = kwargs.get("tools", None)
response = self._call_codex_api(messages, tools=tools)
response = self._call_codex_api(messages, tools=tools, tool_choice=kwargs.get("tool_choice"))
return self._parse_response(response)
def bind_tools(self, tools: list, **kwargs: Any) -> Any:
"""Bind tools for function calling."""
def bind_tools(self, tools: list, *, tool_choice: str | dict | bool | None = None, **kwargs: Any) -> Any:
"""Bind function tools and their selection policy."""
from langchain_core.runnables import RunnableBinding
from langchain_core.tools import BaseTool
from langchain_core.utils.function_calling import convert_to_openai_function
converted_choice = _convert_tool_choice(tool_choice)
formatted_tools = []
for tool in tools:
if isinstance(tool, BaseTool):
@ -495,4 +526,5 @@ class CodexChatModel(BaseChatModel):
else:
formatted_tools.append(tool)
return RunnableBinding(bound=self, kwargs={"tools": formatted_tools}, **kwargs)
_validate_tool_choice(converted_choice, self._convert_tools(formatted_tools))
return RunnableBinding(bound=self, kwargs={"tools": formatted_tools, "tool_choice": converted_choice}, **kwargs)

View File

@ -0,0 +1,195 @@
"""Preserve tool selection through public Codex calls and real HTTP serialization."""
from __future__ import annotations
import asyncio
import copy
import json
import httpx
import pytest
from langchain_core.messages import HumanMessage, ToolMessage
from deerflow.models import openai_codex_provider as provider
from deerflow.models.credential_loader import CodexCliCredential
@pytest.mark.parametrize(
"tool_choice,expected",
[
(None, None),
(False, None),
(True, "required"),
("auto", "auto"),
("none", "none"),
("required", "required"),
("any", "required"),
("read_report", {"type": "function", "name": "read_report"}),
({"type": "function", "name": "read_report"}, {"type": "function", "name": "read_report"}),
({"type": "function", "function": {"name": "read_report"}}, {"type": "function", "name": "read_report"}),
],
)
@pytest.mark.parametrize("binding", ["direct", "bound"])
@pytest.mark.parametrize("invocation", ["invoke", "ainvoke"])
def test_tool_choice_survives_request_and_tool_followup(monkeypatch, tool_choice, expected, binding, invocation):
definition = {"type": "function", "function": {"name": "read_report", "description": "Read a prepared report.", "parameters": {"type": "object", "properties": {}}}}
original_definition = copy.deepcopy(definition)
original_choice = copy.deepcopy(tool_choice)
monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("fake-token", "fake-account"))
model = provider.CodexChatModel(retry_max_attempts=1)
runnable = model.bind_tools([definition], tool_choice=tool_choice) if binding == "bound" else model
kwargs = {} if binding == "bound" else {"tools": [definition], "tool_choice": tool_choice}
requests = []
clients = []
responses = []
client_class = httpx.Client
def handle(request):
payload = json.loads(request.content)
requests.append(payload)
if len(requests) == 1 and payload.get("tool_choice") != "none":
output = [{"type": "function_call", "name": "read_report", "arguments": "{}", "call_id": "call-report"}]
else:
output = [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Report ready."}]}]
event = {"type": "response.completed", "response": {"id": "response-report", "status": "completed", "output": output, "usage": None}}
response = httpx.Response(200, headers={"content-type": "text/event-stream"}, content=f"data: {json.dumps(event)}\n\n".encode())
responses.append(response)
return response
def make_client(**options):
client = client_class(transport=httpx.MockTransport(handle), **options)
clients.append(client)
return client
monkeypatch.setattr(provider.httpx, "Client", make_client)
history = [HumanMessage(content="Read the quarterly report.")]
if invocation == "invoke":
first = runnable.invoke(history, **kwargs)
followup = [*history, first, ToolMessage(content="Report contents", tool_call_id="call-report")] if first.tool_calls else None
answer = runnable.invoke(followup, **{**kwargs, "tool_choice": "none"}) if followup else first
else:
async def exchange():
first = await runnable.ainvoke(history, **kwargs)
followup = [*history, first, ToolMessage(content="Report contents", tool_call_id="call-report")] if first.tool_calls else None
answer = await runnable.ainvoke(followup, **{**kwargs, "tool_choice": "none"}) if followup else first
return first, followup, answer
first, followup, answer = asyncio.run(exchange())
if expected is None:
assert "tool_choice" not in requests[0]
else:
assert requests[0].get("tool_choice") == expected
assert answer.content == "Report ready."
if expected == "none":
assert not first.tool_calls
assert len(requests) == 1
else:
assert first.tool_calls == [{"name": "read_report", "args": {}, "id": "call-report", "type": "tool_call"}]
assert len(requests) == 2
assert requests[1]["tool_choice"] == "none"
assert requests[1]["input"][-2:] == [
{"type": "function_call", "name": "read_report", "arguments": "{}", "call_id": "call-report"},
{"type": "function_call_output", "call_id": "call-report", "output": "Report contents"},
]
assert definition == original_definition
assert tool_choice == original_choice
assert history == [HumanMessage(content="Read the quarterly report.")]
assert all(client.is_closed for client in clients)
assert all(response.is_closed for response in responses)
@pytest.mark.parametrize("tool_choice", [123, [], "", {"type": "file_search"}, {"type": "function"}, {"type": "function", "function": "read_report"}])
def test_invalid_tool_choice_fails_before_http(monkeypatch, tool_choice):
monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("fake-token", "fake-account"))
model = provider.CodexChatModel(retry_max_attempts=1)
definition = {"name": "read_report", "parameters": {"type": "object", "properties": {}}}
def unexpected_http(**options):
pytest.fail("Invalid tool_choice must fail before creating an HTTP client")
monkeypatch.setattr(provider.httpx, "Client", unexpected_http)
with pytest.raises(ValueError, match="tool_choice"):
model.invoke([HumanMessage(content="Read the report.")], tools=[definition], tool_choice=tool_choice)
@pytest.mark.parametrize("tool_choice", [123, [], "", {"type": "file_search"}, {"type": "function"}, {"type": "function", "function": "read_report"}])
def test_invalid_bound_choice_fails_at_bind_time(monkeypatch, tool_choice):
monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("fake-token", "fake-account"))
model = provider.CodexChatModel(retry_max_attempts=1)
with pytest.raises(ValueError, match="tool_choice"):
model.bind_tools([{"name": "read_report"}], tool_choice=tool_choice)
@pytest.mark.parametrize(
"tools,tool_choice",
[
(None, "required"),
([], "any"),
([], "read_report"),
([{"type": "web_search"}], True),
([{"type": "web_search"}], {"type": "function", "function": {"name": "read_report"}}),
([{"name": "other_function"}], "read_report"),
([{"name": "other_function"}], {"type": "function", "name": "read_report"}),
],
)
@pytest.mark.parametrize("invocation", ["invoke", "ainvoke", "bind_tools"])
def test_forced_selection_needs_available_function(monkeypatch, tools, tool_choice, invocation):
monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("fake-token", "fake-account"))
model = provider.CodexChatModel(retry_max_attempts=1)
def unexpected_http(**options):
pytest.fail("Invalid selection must be rejected before creating an HTTP client")
monkeypatch.setattr(provider.httpx, "Client", unexpected_http)
with pytest.raises(ValueError, match="tool_choice"):
if invocation == "bind_tools":
model.bind_tools(tools or [], tool_choice=tool_choice)
elif invocation == "ainvoke":
asyncio.run(model.ainvoke([HumanMessage(content="Read the report.")], tools=tools, tool_choice=tool_choice))
else:
model.invoke([HumanMessage(content="Read the report.")], tools=tools, tool_choice=tool_choice)
@pytest.mark.parametrize("override", [{"tools": []}, {"tool_choice": "missing_function"}, {"tool_choice": {"type": "function"}}])
def test_bound_call_revalidates_invocation_overrides(monkeypatch, override):
monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("fake-token", "fake-account"))
model = provider.CodexChatModel(retry_max_attempts=1)
runnable = model.bind_tools([{"name": "read_report"}], tool_choice="required")
def unexpected_http(**options):
pytest.fail("Invocation overrides must be validated before creating an HTTP client")
monkeypatch.setattr(provider.httpx, "Client", unexpected_http)
with pytest.raises(ValueError, match="tool_choice"):
runnable.invoke([HumanMessage(content="Read the report.")], **override)
@pytest.mark.parametrize(
"tools,tool_choice,invocation",
[(None, None, "invoke"), ([], False, "ainvoke"), ([], "auto", "bound_invoke"), ([], "none", "bound_ainvoke"), ([{"type": "web_search"}], "none", "invoke"), ([{"type": "web_search"}], "auto", "bound_invoke")],
)
def test_no_function_tools_preserves_nonforcing_choices(monkeypatch, tools, tool_choice, invocation):
monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("fake-token", "fake-account"))
model = provider.CodexChatModel(retry_max_attempts=1)
payloads = []
client_class = httpx.Client
def handle(request):
payloads.append(json.loads(request.content))
event = {"type": "response.completed", "response": {"output": [{"type": "message", "content": [{"type": "output_text", "text": "OK"}]}]}}
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=f"data: {json.dumps(event)}\n\n".encode())
monkeypatch.setattr(provider.httpx, "Client", lambda **options: client_class(transport=httpx.MockTransport(handle), **options))
runnable = model.bind_tools(tools or [], tool_choice=tool_choice) if invocation.startswith("bound_") else model
kwargs = {} if invocation.startswith("bound_") else {"tools": tools, "tool_choice": tool_choice}
answer = asyncio.run(runnable.ainvoke([HumanMessage(content="Say OK.")], **kwargs)) if invocation.endswith("ainvoke") else runnable.invoke([HumanMessage(content="Say OK.")], **kwargs)
assert answer.content == "OK"
assert len(payloads) == 1
assert not payloads[0].get("tools")
if tool_choice in ("auto", "none"):
assert payloads[0]["tool_choice"] == tool_choice
else:
assert "tool_choice" not in payloads[0]