Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# fusionAIze Gate Changelog

## v2.1.5 - 2026-04-08

### Fixed

- **Codex tool transcript handling**: assistant tool-call turns and `role: "tool"` follow-up messages are now normalized before they are sent to the ChatGPT Codex responses endpoint, so Codex-backed clients such as Codenomad no longer fall back just because a tool result appears in the message history

## v2.1.4 - 2026-04-08

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion faigate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""fusionAIze Gate package."""

__version__ = "2.1.4"
__version__ = "2.1.5"
20 changes: 20 additions & 0 deletions faigate/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,30 @@ def _build_codex_request_body(
for msg in messages:
role = str(msg.get("role", "user") or "user")
text = self._flatten_text_content(msg.get("content"))
tool_calls = list(msg.get("tool_calls") or [])
if role == "system":
if text:
instructions_parts.append(text)
continue
if role == "assistant" and not text and tool_calls:
rendered_calls: list[str] = []
for call in tool_calls:
fn = dict(call.get("function") or {})
name = str(fn.get("name") or call.get("name") or "tool")
arguments = str(fn.get("arguments") or call.get("arguments") or "").strip()
rendered_calls.append(f"{name}({arguments})" if arguments else name)
if rendered_calls:
text = "Tool calls: " + "; ".join(rendered_calls)
if role == "tool":
tool_name = str(msg.get("name") or "tool")
tool_call_id = str(msg.get("tool_call_id") or "").strip()
label = f"Tool result ({tool_name})"
if tool_call_id:
label = f"{label} [{tool_call_id}]"
text = f"{label}:\n{text}" if text else label
role = "user"
elif role not in {"assistant", "developer", "user"}:
role = "user"
if not text:
continue
input_messages.append({"role": role, "content": text})
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "faigate"
version = "2.1.4"
version = "2.1.5"
description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients."
readme = "README.md"
license = "Apache-2.0"
Expand Down
65 changes: 65 additions & 0 deletions tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,71 @@ async def _fake_post(url, json=None, headers=None, **_kw):
assert result["usage"]["total_tokens"] == 24


@pytest.mark.asyncio
async def test_codex_responses_normalizes_tool_messages():
backend = ProviderBackend(
"openai-codex-5.4-medium",
{
"backend": "openai-compat",
"base_url": "https://chatgpt.com/backend-api/codex/responses",
"api_key": "secret",
"model": "gpt-5.4",
"transport": {"profile": "oauth-codex", "chat_path": ""},
"extra_body": {"reasoning_effort": "medium"},
},
)
captured: dict = {}

class _FakeResp:
status_code = 200
text = (
"event: response.output_text.delta\n"
'data: {"type":"response.output_text.delta","delta":"ok"}\n\n'
"event: response.completed\n"
'data: {"type":"response.completed","response":{"id":"resp_test","created_at":1775616020,'
'"model":"gpt-5-codex","usage":{"input_tokens":19,"output_tokens":5,"total_tokens":24}}}\n\n'
)

async def _fake_post(url, json=None, headers=None, **_kw):
captured["url"] = url
captured["json"] = json or {}
captured["headers"] = headers or {}
return _FakeResp()

backend._client.post = _fake_post # type: ignore[attr-defined]

await backend.complete(
[
{"role": "user", "content": "Use the read_file tool."},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"README.md"}'},
}
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"name": "read_file",
"content": "README content",
},
]
)

assert [item["role"] for item in captured["json"]["input"]] == [
"user",
"assistant",
"user",
]
assert captured["json"]["input"][1]["content"].startswith("Tool calls: read_file(")
assert captured["json"]["input"][2]["content"] == ("Tool result (read_file) [call_1]:\nREADME content")


@pytest.mark.asyncio
async def test_codex_responses_stream_maps_to_openai_chunks():
backend = ProviderBackend(
Expand Down
Loading