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:
Guo-Yixin 2026-10-06 03:53:45 -05:00 committed by GitHub
parent d85f5bf6cc
commit d95da069a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 141 additions and 33 deletions

View File

@ -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`

View File

@ -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,

View File

@ -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)

View 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)