diff --git a/src/openjarvis/engine/cloud.py b/src/openjarvis/engine/cloud.py index d5610f65..88c45bc6 100644 --- a/src/openjarvis/engine/cloud.py +++ b/src/openjarvis/engine/cloud.py @@ -893,6 +893,13 @@ class CloudEngine(InferenceEngine): "max_tokens": max_tokens, "temperature": temperature, } + # Forward tools / tool_choice (OpenRouter is OpenAI-compatible). + tools = kwargs.pop("tools", None) + if tools: + create_kwargs["tools"] = tools + tool_choice = kwargs.pop("tool_choice", None) + if tool_choice is not None: + create_kwargs["tool_choice"] = tool_choice t0 = time.monotonic() resp = self._openrouter_client.chat.completions.create(**create_kwargs) elapsed = time.monotonic() - t0 @@ -900,7 +907,7 @@ class CloudEngine(InferenceEngine): usage = resp.usage prompt_tokens = usage.prompt_tokens if usage else 0 completion_tokens = usage.completion_tokens if usage else 0 - return { + result: Dict[str, Any] = { "content": choice.message.content or "", "usage": { "prompt_tokens": prompt_tokens, @@ -911,6 +918,19 @@ class CloudEngine(InferenceEngine): "finish_reason": choice.finish_reason or "stop", "ttft": elapsed, } + if getattr(choice.message, "tool_calls", None): + result["tool_calls"] = [ + { + "id": tc.id, + "type": tc.type, + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in choice.message.tool_calls + ] + return result def _generate_minimax( self, @@ -1195,6 +1215,13 @@ class CloudEngine(InferenceEngine): "temperature": temperature, "stream": True, } + # Forward tools / tool_choice (OpenRouter is OpenAI-compatible). + tools = kwargs.pop("tools", None) + if tools: + create_kwargs["tools"] = tools + tool_choice = kwargs.pop("tool_choice", None) + if tool_choice is not None: + create_kwargs["tool_choice"] = tool_choice resp = self._openrouter_client.chat.completions.create(**create_kwargs) for chunk in resp: delta = chunk.choices[0].delta if chunk.choices else None diff --git a/tests/engine/test_cloud.py b/tests/engine/test_cloud.py index ed9c22e2..a26671b8 100644 --- a/tests/engine/test_cloud.py +++ b/tests/engine/test_cloud.py @@ -381,3 +381,59 @@ class TestCodexGenerate: engine._codex_client = {"token": "t", "url": "http://test"} engine.close() assert engine._codex_client is None + + +class TestOpenRouterToolForwarding: + """Regression for #511: the OpenRouter engine must forward tools/tool_choice + to the (OpenAI-compatible) API and parse tool_calls back out of the response. + Pre-fix, both were dropped, silently breaking function-calling via OpenRouter. + """ + + def test_generate_forwards_tools_and_parses_tool_calls( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + fake_tc = SimpleNamespace( + id="call_1", + type="function", + function=SimpleNamespace(name="get_weather", arguments='{"city": "NYC"}'), + ) + fake_choice = SimpleNamespace( + message=SimpleNamespace(content=None, tool_calls=[fake_tc]), + finish_reason="tool_calls", + ) + fake_resp = SimpleNamespace( + choices=[fake_choice], + usage=SimpleNamespace(prompt_tokens=3, completion_tokens=2, total_tokens=5), + model="openai/gpt-4o", + ) + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = fake_resp + + EngineRegistry.register_value("cloud", CloudEngine) + engine = CloudEngine() + engine._openrouter_client = fake_client + + tools = [ + { + "type": "function", + "function": {"name": "get_weather", "parameters": {}}, + } + ] + result = engine.generate( + [Message(role=Role.USER, content="weather in NYC?")], + model="openrouter/openai/gpt-4o", + tools=tools, + tool_choice="auto", + ) + + # tools / tool_choice are forwarded to the API call + sent = fake_client.chat.completions.create.call_args.kwargs + assert sent["tools"] == tools + assert sent["tool_choice"] == "auto" + + # tool_calls from the response are parsed back into the result + assert result["tool_calls"][0]["id"] == "call_1" + assert result["tool_calls"][0]["function"]["name"] == "get_weather" + assert result["tool_calls"][0]["function"]["arguments"] == '{"city": "NYC"}'