mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
11979fdb57
commit
a6b3552362
@ -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`
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
195
backend/tests/test_codex_tool_choice.py
Normal file
195
backend/tests/test_codex_tool_choice.py
Normal 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]
|
||||
Loading…
x
Reference in New Issue
Block a user