mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(models): preserve explicit Codex function tool strictness (#6360)
* fix(models): preserve explicit Codex function tool strictness * fix(models): omit null Codex strictness and share BaseTool conversion
This commit is contained in:
parent
d85f5bf6cc
commit
d95da069a6
@ -314,6 +314,7 @@ For Google's official Gemini OpenAI-compatible endpoint, use the
|
||||
```
|
||||
|
||||
- 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.
|
||||
- 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`
|
||||
|
||||
@ -86,6 +86,13 @@ Each omitted tool result emits a warning with its normalized content length;
|
||||
the warning never includes the result content.
|
||||
Coverage: `tests/test_codex_provider.py`.
|
||||
|
||||
Codex function-tool conversion preserves explicit `strict=True` and `False`
|
||||
for wrapped and flat dictionaries; missing or `None` stays omitted to preserve
|
||||
the provider default. Wrapped dictionaries and converted `BaseTool` schemas
|
||||
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 SSE termination (`packages/harness/deerflow/models/openai_codex_provider.py`)
|
||||
|
||||
Completed responses with null, omitted, or empty `usage` retain their text,
|
||||
|
||||
@ -210,23 +210,20 @@ class CodexChatModel(BaseChatModel):
|
||||
for tool in tools:
|
||||
if tool.get("type") == "function" and "function" in tool:
|
||||
fn = tool["function"]
|
||||
responses_tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"name": fn["name"],
|
||||
"description": fn.get("description", ""),
|
||||
"parameters": fn.get("parameters", {}),
|
||||
}
|
||||
)
|
||||
elif "name" in tool:
|
||||
responses_tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"name": tool["name"],
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": tool.get("parameters", {}),
|
||||
}
|
||||
)
|
||||
fn = tool
|
||||
else:
|
||||
continue
|
||||
converted = {
|
||||
"type": "function",
|
||||
"name": fn["name"],
|
||||
"description": fn.get("description", ""),
|
||||
"parameters": fn.get("parameters", {}),
|
||||
}
|
||||
# Omitting strict is not equivalent to the caller's explicit False.
|
||||
if fn.get("strict") is not None:
|
||||
converted["strict"] = fn["strict"]
|
||||
responses_tools.append(converted)
|
||||
return responses_tools
|
||||
|
||||
def _call_codex_api(self, messages: list[BaseMessage], tools: list[dict] | None = None) -> dict:
|
||||
@ -474,14 +471,7 @@ class CodexChatModel(BaseChatModel):
|
||||
if isinstance(tool, BaseTool):
|
||||
try:
|
||||
fn = convert_to_openai_function(tool)
|
||||
formatted_tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"name": fn["name"],
|
||||
"description": fn.get("description", ""),
|
||||
"parameters": fn.get("parameters", {}),
|
||||
}
|
||||
)
|
||||
formatted_tools.extend(self._convert_tools([fn]))
|
||||
except Exception:
|
||||
formatted_tools.append(
|
||||
{
|
||||
@ -493,15 +483,7 @@ class CodexChatModel(BaseChatModel):
|
||||
)
|
||||
elif isinstance(tool, dict):
|
||||
if "function" in tool:
|
||||
fn = tool["function"]
|
||||
formatted_tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"name": fn["name"],
|
||||
"description": fn.get("description", ""),
|
||||
"parameters": fn.get("parameters", {}),
|
||||
}
|
||||
)
|
||||
formatted_tools.extend(self._convert_tools([{"type": "function", "function": tool["function"]}]))
|
||||
else:
|
||||
formatted_tools.append(tool)
|
||||
|
||||
|
||||
118
backend/tests/test_codex_tool_strict.py
Normal file
118
backend/tests/test_codex_tool_strict.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Keep caller-supplied function strictness through real Codex HTTP requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langchain_core.utils import function_calling
|
||||
|
||||
from deerflow.models import openai_codex_provider as provider
|
||||
from deerflow.models.credential_loader import CodexCliCredential
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape,binding", [(shape, binding) for shape in ("wrapped", "flat", "bare") for binding in ("direct", "bound")] + [("base_tool", "bound")])
|
||||
@pytest.mark.parametrize("strict", [True, False, None, "omitted"])
|
||||
@pytest.mark.parametrize("invocation", ["invoke", "ainvoke"])
|
||||
def test_function_strictness_survives_tool_followup(monkeypatch, shape, strict, binding, invocation):
|
||||
function = {
|
||||
"name": "read_report",
|
||||
"description": "Read a prepared report.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}, "limit": {"type": ["integer", "null"]}},
|
||||
"required": ["query"] if strict is False else ["query", "limit"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
if strict != "omitted":
|
||||
function["strict"] = strict
|
||||
if shape == "wrapped":
|
||||
definition = {"type": "function", "function": function}
|
||||
elif shape == "flat":
|
||||
definition = {"type": "function", **function}
|
||||
elif shape == "bare":
|
||||
definition = function
|
||||
else:
|
||||
definition = StructuredTool.from_function(lambda query, limit=None: "unused", name=function["name"], description=function["description"], args_schema=function["parameters"])
|
||||
convert = function_calling.convert_to_openai_function
|
||||
|
||||
def convert_with_strict(tool):
|
||||
converted = convert(tool)
|
||||
if "strict" in function:
|
||||
converted["strict"] = function["strict"]
|
||||
return converted
|
||||
|
||||
if "strict" in function:
|
||||
# Exercise a converter-emitted setting without claiming the locked
|
||||
# LangChain release currently adds strictness to BaseTool schemas.
|
||||
monkeypatch.setattr(function_calling, "convert_to_openai_function", convert_with_strict)
|
||||
original_definition = copy.deepcopy(definition)
|
||||
expected_tool = {"type": "function", **function}
|
||||
if expected_tool.get("strict") is None:
|
||||
expected_tool.pop("strict", None)
|
||||
arguments = {"query": "quarterly"}
|
||||
if strict is not False:
|
||||
arguments["limit"] = None
|
||||
|
||||
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]) if binding == "bound" else model
|
||||
kwargs = {} if binding == "bound" else {"tools": [definition]}
|
||||
requests = []
|
||||
clients = []
|
||||
responses = []
|
||||
client_class = httpx.Client
|
||||
|
||||
def handle(request):
|
||||
payload = json.loads(request.content)
|
||||
requests.append(payload)
|
||||
if len(requests) == 1:
|
||||
output = [{"type": "function_call", "name": "read_report", "arguments": json.dumps(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":
|
||||
call = runnable.invoke(history, **kwargs)
|
||||
followup = [*history, call, ToolMessage(content="Report contents", tool_call_id="call-report")]
|
||||
answer = runnable.invoke(followup, **kwargs)
|
||||
else:
|
||||
|
||||
async def exchange():
|
||||
call = await runnable.ainvoke(history, **kwargs)
|
||||
followup = [*history, call, ToolMessage(content="Report contents", tool_call_id="call-report")]
|
||||
answer = await runnable.ainvoke(followup, **kwargs)
|
||||
return call, followup, answer
|
||||
|
||||
call, followup, answer = asyncio.run(exchange())
|
||||
|
||||
assert call.tool_calls == [{"name": "read_report", "args": arguments, "id": "call-report", "type": "tool_call"}]
|
||||
assert answer.content == "Report ready."
|
||||
assert len(requests) == 2
|
||||
assert all(payload["tools"] == [expected_tool] for payload in requests)
|
||||
assert requests[1]["input"][-2:] == [
|
||||
{"type": "function_call", "name": "read_report", "arguments": json.dumps(arguments), "call_id": "call-report"},
|
||||
{"type": "function_call_output", "call_id": "call-report", "output": "Report contents"},
|
||||
]
|
||||
assert definition == original_definition
|
||||
assert history == [HumanMessage(content="Read the quarterly report.")]
|
||||
assert followup[-1].content == "Report contents"
|
||||
assert all(client.is_closed for client in clients)
|
||||
assert all(response.is_closed for response in responses)
|
||||
Loading…
x
Reference in New Issue
Block a user