diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5c5fe..a360f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/faigate/__init__.py b/faigate/__init__.py index 91b81b4..8b9e581 100644 --- a/faigate/__init__.py +++ b/faigate/__init__.py @@ -1,3 +1,3 @@ """fusionAIze Gate package.""" -__version__ = "2.1.4" +__version__ = "2.1.5" diff --git a/faigate/providers.py b/faigate/providers.py index ce49ced..2e0d4f2 100644 --- a/faigate/providers.py +++ b/faigate/providers.py @@ -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}) diff --git a/pyproject.toml b/pyproject.toml index 3c26e6c..4270b53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_providers.py b/tests/test_providers.py index 9740401..ee193ee 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -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(