From dc403a8202da309f7d2824f614d4ec9330422bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Wed, 8 Apr 2026 20:15:13 +0200 Subject: [PATCH] fix: bridge codex function tool calls back to openai clients --- CHANGELOG.md | 6 ++ faigate/__init__.py | 2 +- faigate/providers.py | 123 ++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/test_providers.py | 166 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 294 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a360f42..6251074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # fusionAIze Gate Changelog +## v2.1.6 - 2026-04-08 + +### Fixed + +- **Codex function tool calling**: Codex-backed requests now forward OpenAI-style `tools` and `tool_choice` to the ChatGPT Codex responses endpoint and translate returned function-call events back into OpenAI-compatible `tool_calls`, so tool-using clients like Codenomad can execute MCP-style tool flows instead of only seeing text or pseudo-JSON + ## v2.1.5 - 2026-04-08 ### Fixed diff --git a/faigate/__init__.py b/faigate/__init__.py index 8b9e581..37ab677 100644 --- a/faigate/__init__.py +++ b/faigate/__init__.py @@ -1,3 +1,3 @@ """fusionAIze Gate package.""" -__version__ = "2.1.5" +__version__ = "2.1.6" diff --git a/faigate/providers.py b/faigate/providers.py index 2e0d4f2..81d61d1 100644 --- a/faigate/providers.py +++ b/faigate/providers.py @@ -239,6 +239,7 @@ def _build_codex_request_body( *, model: str, stream: bool, + tools: list[dict[str, Any]] | None = None, extra_body: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build a ChatGPT Codex responses payload from OpenAI-style messages.""" @@ -282,11 +283,56 @@ def _build_codex_request_body( "store": False, "stream": True, } + codex_tools = self._codex_tools(tools) + if codex_tools: + body["tools"] = codex_tools + codex_tool_choice = self._codex_tool_choice(extra_body=extra_body) + if codex_tool_choice not in (None, "", [], {}): + body["tool_choice"] = codex_tool_choice reasoning = self._codex_reasoning_config(extra_body=extra_body) if reasoning: body["reasoning"] = reasoning return body + def _codex_tools(self, tools: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + """Translate OpenAI chat tools into Codex responses tools.""" + if not tools: + return [] + translated: list[dict[str, Any]] = [] + for tool in tools: + if str(tool.get("type") or "") != "function": + continue + fn = dict(tool.get("function") or {}) + name = str(fn.get("name") or "").strip() + if not name: + continue + entry: dict[str, Any] = {"type": "function", "name": name} + description = str(fn.get("description") or "").strip() + if description: + entry["description"] = description + parameters = fn.get("parameters") + if isinstance(parameters, dict): + entry["parameters"] = parameters + translated.append(entry) + return translated + + def _codex_tool_choice(self, *, extra_body: dict[str, Any] | None = None) -> Any: + """Translate OpenAI chat tool_choice into Codex responses shape.""" + if not extra_body: + return None + choice = extra_body.get("tool_choice") + if isinstance(choice, str): + return choice + if not isinstance(choice, dict): + return None + if str(choice.get("type") or "") != "function": + return choice + fn = dict(choice.get("function") or {}) + name = str(fn.get("name") or "").strip() + if not name: + return "auto" + return {"type": "function", "name": name} + def _iter_sse_events(self, payload: str) -> list[dict[str, Any]]: """Parse one completed SSE payload into JSON event objects.""" events: list[dict[str, Any]] = [] @@ -328,12 +374,26 @@ def _codex_completion_from_sse( events = self._iter_sse_events(payload) response_meta: dict[str, Any] = {} text_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] for event in events: event_type = str(event.get("type", "") or "") if event_type == "response.output_text.delta": text_parts.append(str(event.get("delta") or "")) elif event_type == "response.output_text.done" and not text_parts: text_parts.append(str(event.get("text") or "")) + elif event_type == "response.output_item.done": + item = dict(event.get("item") or {}) + if str(item.get("type") or "") == "function_call": + tool_calls.append( + { + "id": str(item.get("call_id") or item.get("id") or ""), + "type": "function", + "function": { + "name": str(item.get("name") or ""), + "arguments": str(item.get("arguments") or "{}"), + }, + } + ) elif event_type == "response.completed": response_meta = dict(event.get("response") or {}) @@ -343,6 +403,7 @@ def _codex_completion_from_sse( total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens)) content = "".join(text_parts).strip() + finish_reason = "tool_calls" if tool_calls else "stop" return { "id": response_meta.get("id") or f"chatcmpl-{int(time.time() * 1000)}", "object": "chat.completion", @@ -351,8 +412,12 @@ def _codex_completion_from_sse( "choices": [ { "index": 0, - "message": {"role": "assistant", "content": content}, - "finish_reason": "stop", + "message": { + "role": "assistant", + "content": content, + **({"tool_calls": tool_calls} if tool_calls else {}), + }, + "finish_reason": finish_reason, } ], "usage": { @@ -395,6 +460,7 @@ async def _stream_codex_response( emitted_role = False data_lines: list[str] = [] saw_content = False + saw_tool_calls = False async for raw_line in resp.aiter_lines(): line = raw_line.strip() @@ -448,10 +514,60 @@ async def _stream_codex_response( ) continue + if event_type == "response.output_item.done": + item = dict(event.get("item") or {}) + if str(item.get("type") or "") != "function_call": + continue + saw_tool_calls = True + if not emitted_role: + yield self._openai_sse_chunk( + { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model_name, + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], + } + ) + emitted_role = True + yield self._openai_sse_chunk( + { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model_name, + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "id": str(item.get("call_id") or item.get("id") or ""), + "type": "function", + "function": { + "name": str(item.get("name") or ""), + "arguments": str(item.get("arguments") or "{}"), + }, + } + ] + }, + "finish_reason": None, + } + ], + } + ) + continue + if event_type == "response.completed": response = dict(event.get("response") or {}) completion_id = str(response.get("id") or completion_id) model_name = str(response.get("model") or model_name) + finish_reason = "tool_calls" if saw_tool_calls else "stop" + for output_item in list(response.get("output") or []): + if str(dict(output_item).get("type") or "") == "function_call": + finish_reason = "tool_calls" + break if not saw_content: self.health.record_success((time.time() - t0) * 1000) yield self._openai_sse_chunk( @@ -460,7 +576,7 @@ async def _stream_codex_response( "object": "chat.completion.chunk", "created": int(response.get("created_at") or created), "model": model_name, - "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], } ) yield b"data: [DONE]\n\n" @@ -953,6 +1069,7 @@ async def complete( messages, model=model, stream=stream, + tools=tools, extra_body=extra_body, ) headers = self._authorization_headers(content_type="application/json") diff --git a/pyproject.toml b/pyproject.toml index 4270b53..67504de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faigate" -version = "2.1.5" +version = "2.1.6" 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 ee193ee..18fc509 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -480,6 +480,96 @@ async def _fake_post(url, json=None, headers=None, **_kw): assert captured["json"]["input"][2]["content"] == ("Tool result (read_file) [call_1]:\nREADME content") +@pytest.mark.asyncio +async def test_codex_responses_forward_tools_and_return_tool_calls(): + 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_item.added\n" + 'data: {"type":"response.output_item.added","item":{"id":"fc_1",' + '"type":"function_call","status":"in_progress","arguments":"","call_id":"call_1",' + '"name":"respira_get_site_context"},"output_index":1,"sequence_number":4}\n\n' + "event: response.function_call_arguments.delta\n" + 'data: {"type":"response.function_call_arguments.delta","delta":"{}",' + '"item_id":"fc_1","output_index":1,"sequence_number":5}\n\n' + "event: response.function_call_arguments.done\n" + 'data: {"type":"response.function_call_arguments.done","arguments":"{}",' + '"item_id":"fc_1","output_index":1,"sequence_number":6}\n\n' + "event: response.output_item.done\n" + 'data: {"type":"response.output_item.done","item":{"id":"fc_1",' + '"type":"function_call","status":"completed","arguments":"{}","call_id":"call_1",' + '"name":"respira_get_site_context"},"output_index":1,"sequence_number":7}\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 {} + return _FakeResp() + + backend._client.post = _fake_post # type: ignore[attr-defined] + + result = await backend.complete( + [{"role": "user", "content": "Use the site context tool."}], + tools=[ + { + "type": "function", + "function": { + "name": "respira_get_site_context", + "description": "Get the current site context.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + }, + } + ], + extra_body={"tool_choice": "auto"}, + ) + + assert captured["json"]["tools"] == [ + { + "type": "function", + "name": "respira_get_site_context", + "description": "Get the current site context.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + } + ] + assert captured["json"]["tool_choice"] == "auto" + assert result["choices"][0]["finish_reason"] == "tool_calls" + assert result["choices"][0]["message"]["tool_calls"] == [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "respira_get_site_context", + "arguments": "{}", + }, + } + ] + + @pytest.mark.asyncio async def test_codex_responses_stream_maps_to_openai_chunks(): backend = ProviderBackend( @@ -548,6 +638,82 @@ def _fake_stream(method, url, json=None, headers=None, **_kw): assert "data: [DONE]" in payload +@pytest.mark.asyncio +async def test_codex_responses_stream_maps_tool_calls_to_openai_chunks(): + 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": ""}, + }, + ) + + class _FakeStreamResp: + status_code = 200 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def aiter_lines(self): + for line in [ + ( + 'data: {"type":"response.created","response":{"id":"resp_stream",' + '"created_at":1775616020,"model":"gpt-5-codex"}}' + ), + "", + ( + 'data: {"type":"response.output_item.done","item":{"id":"fc_1",' + '"type":"function_call",' + '"status":"completed","arguments":"{}","call_id":"call_1",' + '"name":"respira_get_site_context"}}' + ), + "", + ( + 'data: {"type":"response.completed","response":{"id":"resp_stream",' + '"created_at":1775616020,"model":"gpt-5-codex","usage":{"input_tokens":19,' + '"output_tokens":5,"total_tokens":24}}}' + ), + "", + ]: + yield line + + def _fake_stream(method, url, json=None, headers=None, **_kw): + return _FakeStreamResp() + + backend._client.stream = _fake_stream # type: ignore[attr-defined] + + stream_iter = await backend.complete( + [{"role": "user", "content": "Use the site context tool."}], + tools=[ + { + "type": "function", + "function": { + "name": "respira_get_site_context", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + }, + } + ], + stream=True, + ) + payload = b"".join([chunk async for chunk in stream_iter]).decode("utf-8") + + assert '"tool_calls":[{"index":0,"id":"call_1","type":"function"' in payload + assert '"name":"respira_get_site_context"' in payload + assert '"arguments":"{}"' in payload + assert '"finish_reason":"tool_calls"' in payload + assert "data: [DONE]" in payload + + def test_oauth_backend_resolves_brew_libexec_helper(monkeypatch, tmp_path: Path): libexec_bin = tmp_path / "libexec" / "bin" libexec_bin.mkdir(parents=True)