From af0796689f0829b726ffa2633dccd25ab41125a8 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 20 Jun 2026 23:55:03 +0200 Subject: [PATCH] make openrouter default to provider supporting tool use --- routstr/upstream/openrouter.py | 33 ++++++++++ tests/unit/test_require_parameters.py | 95 +++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 tests/unit/test_require_parameters.py diff --git a/routstr/upstream/openrouter.py b/routstr/upstream/openrouter.py index 36932d22..0c69d2cc 100644 --- a/routstr/upstream/openrouter.py +++ b/routstr/upstream/openrouter.py @@ -1,3 +1,4 @@ +import json from typing import TYPE_CHECKING import httpx @@ -18,6 +19,38 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider): supports_anthropic_messages = True litellm_provider_prefix = "openrouter/" + def prepare_request_body( + self, body: bytes | None, model_obj: Model + ) -> bytes | None: + """Set provider.require_parameters on tool-use requests. + + Without it OpenRouter can route a tool call to an endpoint that doesn't + support function calling and 404 with "No endpoints found that support + tool use". We leave a client-supplied value untouched. + """ + body = super().prepare_request_body(body, model_obj) + if not body: + return body + + try: + data = json.loads(body) + except json.JSONDecodeError: + return body + + if not isinstance(data, dict) or not data.get("tools"): + return body + + provider = data.get("provider") + if not isinstance(provider, dict): + provider = {} + + if "require_parameters" in provider: + return body + + provider["require_parameters"] = True + data["provider"] = provider + return json.dumps(data).encode() + def _apply_provider_field(self, response_json: object) -> None: """Stamp the ``provider`` field for OpenRouter responses. diff --git a/tests/unit/test_require_parameters.py b/tests/unit/test_require_parameters.py new file mode 100644 index 00000000..83c90652 --- /dev/null +++ b/tests/unit/test_require_parameters.py @@ -0,0 +1,95 @@ +import json +import os + +os.environ.setdefault("UPSTREAM_BASE_URL", "http://test") +os.environ.setdefault("UPSTREAM_API_KEY", "test") +os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to") + +from routstr.upstream import GenericUpstreamProvider +from routstr.upstream.openrouter import OpenRouterUpstreamProvider + + +def _model(model_id: str = "openai/gpt-4o"): # type: ignore[no-untyped-def] + from routstr.payment.models import Architecture, Model, Pricing + + return Model( + id=model_id, + name=model_id, + created=0, + description="", + context_length=128000, + architecture=Architecture( + modality="text->text", + input_modalities=["text"], + output_modalities=["text"], + tokenizer="GPT", + instruct_type=None, + ), + pricing=Pricing(prompt=0.0, completion=0.0), + ) + + +def _tool_body() -> dict: + return { + "model": "openai/gpt-4o", + "messages": [{"role": "user", "content": "What's the weather?"}], + "tools": [ + { + "type": "function", + "function": {"name": "get_weather", "parameters": {}}, + } + ], + } + + +def _prepare(provider, body: dict) -> dict: # type: ignore[no-untyped-def] + out = provider.prepare_request_body(json.dumps(body).encode(), _model()) + assert out is not None + return json.loads(out) + + +def test_injects_require_parameters_for_tool_request() -> None: + data = _prepare(OpenRouterUpstreamProvider(api_key="test"), _tool_body()) + assert data["provider"]["require_parameters"] is True + + +def test_generic_provider_on_openrouter_url_is_left_alone() -> None: + # Only OpenRouterUpstreamProvider injects; a generic provider pointed at the + # same base URL doesn't. + provider = GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1") + data = _prepare(provider, _tool_body()) + assert "provider" not in data + + +def test_no_injection_without_tools() -> None: + body = {"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "hi"}]} + data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body) + assert "provider" not in data + + +def test_empty_tools_list_does_not_inject() -> None: + body = _tool_body() + body["tools"] = [] + data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body) + assert "provider" not in data + + +def test_direct_provider_does_not_inject() -> None: + provider = GenericUpstreamProvider(base_url="https://api.openai.com/v1") + data = _prepare(provider, _tool_body()) + assert "provider" not in data + + +def test_keeps_client_set_require_parameters() -> None: + body = _tool_body() + body["provider"] = {"require_parameters": False} + data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body) + assert data["provider"]["require_parameters"] is False + + +def test_preserves_other_provider_fields() -> None: + body = _tool_body() + body["provider"] = {"order": ["openai", "azure"]} + data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body) + assert data["provider"]["order"] == ["openai", "azure"] + assert data["provider"]["require_parameters"] is True