From f1d4b3c175596b2aba96dc41ff0c1519b0140263 Mon Sep 17 00:00:00 2001 From: routstr-dev Date: Sun, 4 Oct 2026 15:05:55 +0800 Subject: [PATCH] fix(ehbp): restore the version prefix on EHBP upstream paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy accepts an API endpoint with or without a leading `v1/` (`_canonical_api_path`) and forwards the caller's path verbatim, so a client that spells the endpoint `chat/completions` reaches the upstream as `chat/completions`. That is fine for providers whose `default_base_url` carries the version prefix (openai, groq, fireworks, ...) — the client's `v1/` is stripped and the base URL re-adds its own — but Tinfoil's base URL is unversioned and its router serves only `/v1/...`. Every Tinfoil request from such a client therefore got 404 {"error":{"message":"Not found.","type":"invalid_request_error"}} from `https://inference.tinfoil.sh/chat/completions`, while the same request with the prefix succeeded. Tinfoil's own error text blamed the model id, which sent the search in the wrong direction. Give the EHBP path builders the same hooks the non-EHBP forwarding path uses: `normalize_request_path` strips the client's optional `v1/`, and a new `ehbp_path_prefix` re-adds the prefix the provider's enclave actually serves (`v1` for Tinfoil, `private/v1` for PPQ.AI, whose target had the same latent bug). Both spellings now reach the same upstream URL. `_resolve_ehbp_target_url` re-appended the caller's raw path to the client-supplied enclave URL, re-introducing the spelling the provider had just normalized away; it now takes the path from the target URL the provider built, so the override swaps the host only. --- routstr/upstream/base.py | 26 +++++ routstr/upstream/ehbp.py | 17 ++-- routstr/upstream/ppqai.py | 9 +- routstr/upstream/tinfoil.py | 12 ++- tests/unit/test_tinfoil_integration.py | 134 ++++++++++++++++++++++++- 5 files changed, 185 insertions(+), 13 deletions(-) diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 4c4b68e8..bd11e3c7 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -699,6 +699,32 @@ class BaseUpstreamProvider: clean_path = path.lstrip("/") return f"{self.get_request_base_url(path, model_obj)}/{clean_path}" + # Path segment the enclave expects between its base URL and the API path. + # Providers whose ``default_base_url`` already carries a version prefix + # (openai, groq, fireworks, ...) leave this empty: ``normalize_request_path`` + # strips the client's ``v1/`` and the base URL re-adds its own. EHBP + # providers whose base URL does not carry one (tinfoil, ppqai) set it, so a + # client that spells the endpoint without ``v1/`` — which the node accepts + # as an equivalent spelling (``_canonical_api_path``) — still reaches the + # enclave's versioned route instead of a 404. + ehbp_path_prefix: str = "" + + def build_ehbp_request_path(self, path: str, model_obj: Model | None = None) -> str: + """Build the EHBP forwarding path, restoring the provider's version prefix. + + The node accepts an API endpoint with or without a leading ``v1/``, so + the client's spelling carries no information about what the upstream + serves. Normalize it away with the same hook the non-EHBP forwarding + path uses, then re-add this provider's prefix: only the provider knows + whether its enclave serves ``/v1/...``, ``/private/v1/...``, or an + unversioned path. + """ + clean_path = self.normalize_request_path(path, model_obj).lstrip("/") + prefix = self.ehbp_path_prefix.strip("/") + if not prefix: + return clean_path + return f"{prefix}/{clean_path}" + def prepare_responses_request_body( self, body: bytes | None, model_obj: Model ) -> bytes | None: diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index c1ee8cfc..5a2cbf47 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -269,7 +269,6 @@ def _validated_tinfoil_enclave_base_url(enclave_url: str) -> str | None: def _resolve_ehbp_target_url( target_url: str, - path: str, headers: Mapping[str, str], provider_type: str | None = None, profile: "ConfidentialInferenceProfile | None" = None, @@ -281,6 +280,14 @@ def _resolve_ehbp_target_url( opt in to client-supplied target overrides and constrain the destination; otherwise the header is ignored so callers cannot redirect other providers or leak upstream API keys. + + Only the *host* is taken from the override: the path comes from + ``target_url``, which the provider built through + :meth:`~routstr.upstream.base.BaseUpstreamProvider.build_ehbp_request_path`. + Appending the caller's raw path here instead would re-introduce the + spelling the provider just normalized away, so a client that posts to + ``/chat/completions`` would reach the enclave's unversioned (404) route + even though the default target was built correctly. """ override_header = ( profile.client_target_url_header if profile else _ENCLAVE_URL_HEADER @@ -318,7 +325,7 @@ def _resolve_ehbp_target_url( status_code=400, ) - return f"{validated_base_url}/{path.lstrip('/')}" + return f"{validated_base_url}{urlsplit(target_url).path}" def _validated_confidential_target_url( @@ -845,9 +852,7 @@ async def forward_ehbp_request( provider_type = getattr(upstream, "provider_type", "unknown") profile = target.profile or upstream.get_confidential_inference_profile() # type: ignore[attr-defined] - target_url = _resolve_ehbp_target_url( - target.url, path, headers, provider_type, profile - ) + target_url = _resolve_ehbp_target_url(target.url, headers, provider_type, profile) upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers, profile) # Merge query params into the target URL since forward_with_trailer @@ -1128,7 +1133,7 @@ async def forward_ehbp_x_cashu_request( provider_type = getattr(upstream, "provider_type", "unknown") profile = target.profile or upstream.get_confidential_inference_profile() # type: ignore[attr-defined] target_url = _resolve_ehbp_target_url( - target.url, path, headers, provider_type, profile + target.url, headers, provider_type, profile ) upstream_headers = _prepare_ehbp_upstream_headers( headers, target.headers, profile diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py index 65ccbb57..6471a107 100644 --- a/routstr/upstream/ppqai.py +++ b/routstr/upstream/ppqai.py @@ -124,6 +124,10 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): provider_type = "ppqai" default_base_url = "https://api.ppq.ai" platform_url = "https://ppq.ai/api-docs" + # PPQ.AI's private enclave lives under ``/private/v1/...`` and the base URL + # carries no version prefix, so the EHBP target re-adds the whole prefix. + # (EHBP is currently disabled for this provider — see ``supports_ehbp``.) + ehbp_path_prefix = "private/v1" IGNORED_MODEL_IDS: list[str] = ["auto"] # PPQ.AI has a private encrypted endpoint, but this proxy currently has no # provider-attested usage extractor/model binding for it. Keep EHBP disabled @@ -170,7 +174,10 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): Routstr, so PPQ.AI also needs X-Private-Model for routing/billing. """ return EHBPForwardingTarget( - url=f"{self.base_url.rstrip('/')}/private/{path.lstrip('/')}", + url=( + f"{self.base_url.rstrip('/')}/" + f"{self.build_ehbp_request_path(path, model_obj)}" + ), headers={"X-Private-Model": model_obj.forwarded_model_id or model_obj.id}, ) diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py index 63467fa4..70c40cf0 100644 --- a/routstr/upstream/tinfoil.py +++ b/routstr/upstream/tinfoil.py @@ -67,6 +67,13 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): default_base_url = "https://inference.tinfoil.sh" platform_url = "https://docs.tinfoil.sh" supports_ehbp = True + # Tinfoil's router serves only its versioned API surface (``/v1/...``), and + # unlike most providers its base URL does not carry the prefix — + # ``fetch_models`` adds ``/v1`` explicitly for ``GET /v1/models``. The EHBP + # target must re-add it: a client that posts to ``/chat/completions`` + # otherwise reaches ``https://inference.tinfoil.sh/chat/completions`` and + # gets a 404 ("Not found.") from the router. + ehbp_path_prefix = "v1" confidential_inference_profile = ConfidentialInferenceProfile( usage_response_header=_RESPONSE_USAGE_HEADER, client_target_url_header=_ENCLAVE_URL_HEADER, @@ -191,7 +198,10 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): sends it (see ``routstr/upstream/ehbp.py``). """ return EHBPForwardingTarget( - url=f"{self.base_url.rstrip('/')}/{path.lstrip('/')}", + url=( + f"{self.base_url.rstrip('/')}/" + f"{self.build_ehbp_request_path(path, model_obj)}" + ), headers={"X-Tinfoil-Request-Usage-Metrics": "true"}, profile=self.confidential_inference_profile, ) diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 14715f58..b80945d0 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -200,16 +200,35 @@ class TestResolveEhbpTargetUrl: def test_override_with_enclave_url_for_tinfoil(self) -> None: result = _resolve_ehbp_target_url( "https://default.example.com/v1/chat/completions", - "v1/chat/completions", {"X-Tinfoil-Enclave-Url": "https://enclave.tinfoil.sh"}, "tinfoil", ) assert result == "https://enclave.tinfoil.sh/v1/chat/completions" + def test_override_keeps_the_providers_version_prefix(self) -> None: + """The override swaps the host only, never the path. + + The provider has already re-added the version prefix the enclave + requires, so a client that spelled the endpoint ``chat/completions`` + must still land on ``/v1/chat/completions``: this was the path that + turned every SDK-style request into a paid upstream 404 against + ``router-0.tinfoil.sh``. + """ + provider = TinfoilUpstreamProvider(api_key="test") + model_obj = MagicMock() + model_obj.id = "tinfoil-deepseek-v4-1-flash" + model_obj.forwarded_model_id = "deepseek-v4-1-flash" + target = provider.get_ehbp_forwarding_target("chat/completions", model_obj) + result = _resolve_ehbp_target_url( + target.url, + {"X-Tinfoil-Enclave-Url": "https://router-0.tinfoil.sh"}, + "tinfoil", + ) + assert result == "https://router-0.tinfoil.sh/v1/chat/completions" + def test_override_lowercase_header_for_tinfoil(self) -> None: result = _resolve_ehbp_target_url( "https://default.example.com/v1/chat/completions", - "v1/chat/completions", {"x-tinfoil-enclave-url": "https://enclave.tinfoil.sh"}, "tinfoil", ) @@ -219,7 +238,6 @@ class TestResolveEhbpTargetUrl: default = "https://inference.tinfoil.sh/v1/chat/completions" result = _resolve_ehbp_target_url( default, - "v1/chat/completions", {}, "tinfoil", ) @@ -229,7 +247,6 @@ class TestResolveEhbpTargetUrl: default = "https://api.ppq.ai/private/v1/chat/completions" result = _resolve_ehbp_target_url( default, - "v1/chat/completions", {"X-Tinfoil-Enclave-Url": "https://enclave.tinfoil.sh"}, "ppqai", ) @@ -252,7 +269,6 @@ class TestResolveEhbpTargetUrl: with pytest.raises(UpstreamError): _resolve_ehbp_target_url( "https://default.example.com/v1/chat/completions", - "v1/chat/completions", {"X-Tinfoil-Enclave-Url": bad_url}, "tinfoil", ) @@ -970,6 +986,37 @@ class TestTinfoilUpstreamProvider: assert target.headers["X-Tinfoil-Request-Usage-Metrics"] == "true" assert "v1/chat/completions" in target.url + @pytest.mark.parametrize("client_path", ["v1/chat/completions", "chat/completions"]) + def test_get_ehbp_forwarding_target_is_versioned_for_both_spellings( + self, client_path: str + ) -> None: + """The node accepts ``chat/completions`` and ``v1/chat/completions`` as + the same endpoint, so both must reach Tinfoil's versioned route. + + A bare client path used to produce + ``https://inference.tinfoil.sh/chat/completions``, which the router + answers with 404 ``{"error":{"message":"Not found."}}``. + """ + provider = TinfoilUpstreamProvider(api_key="test") + model_obj = MagicMock() + model_obj.id = "tinfoil-deepseek-v4-1-flash" + model_obj.forwarded_model_id = "deepseek-v4-1-flash" + target = provider.get_ehbp_forwarding_target(client_path, model_obj) + assert target.url == "https://inference.tinfoil.sh/v1/chat/completions" + + def test_get_ehbp_forwarding_target_does_not_double_the_prefix(self) -> None: + """A path that already carries ``v1/`` is normalized, not prefixed again.""" + provider = TinfoilUpstreamProvider(api_key="test") + model_obj = MagicMock() + assert ( + provider.build_ehbp_request_path("v1/chat/completions", model_obj) + == "v1/chat/completions" + ) + assert ( + provider.build_ehbp_request_path("chat/completions", model_obj) + == "v1/chat/completions" + ) + def test_get_provider_metadata(self) -> None: meta = TinfoilUpstreamProvider.get_provider_metadata() assert meta["id"] == "tinfoil" @@ -1375,3 +1422,80 @@ async def test_x_cashu_key_config_422_refunds_and_sets_x_cashu_header() -> None: assert response.headers["content-type"] == "application/problem+json" assert response.body == upstream_resp.body assert response.headers["x-cashu"] == "cashuArefund" + + +# --------------------------------------------------------------------------- +# The forwarded upstream path (regression: bare /chat/completions -> 404) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize("client_path", ["v1/chat/completions", "chat/completions"]) +async def test_proxy_reaches_the_enclaves_versioned_route_for_both_spellings( + client_path: str, +) -> None: + """The caller's spelling must not decide which upstream path is used. + + The proxy treats ``chat/completions`` and ``v1/chat/completions`` as the + same endpoint (``_canonical_api_path``) and forwards the caller's path + verbatim, so the version prefix the enclave requires has to be re-added + downstream by the provider. Before that, a client that spelled the endpoint + without ``v1/`` reached ``https://inference.tinfoil.sh/chat/completions`` + and got a 404 ``{"error":{"message":"Not found."}}`` from the router for + every Tinfoil model, while the same request with the prefix succeeded. + """ + key = ApiKey(hashed_key="keyconfig", balance=10_000) + session = MagicMock() + reservation_snapshot = MagicMock() + + request = MagicMock() + request.method = "POST" + request.headers = { + "authorization": "Bearer sk-keyconfig", + "ehbp-encapsulated-key": "abc123", + "x-routstr-model": "tinfoil-deepseek-v4-1-flash", + } + request.body = AsyncMock(return_value=b"sealed-body") + request.query_params = {} + + model_obj = MagicMock() + model_obj.id = "tinfoil-deepseek-v4-1-flash" + model_obj.forwarded_model_id = "deepseek-v4-1-flash" + upstream = TinfoilUpstreamProvider(api_key="upstream-key") + + # A 422 key-config response short-circuits the billing path while still + # exercising the URL the request was actually sent to. + forward_mock = AsyncMock(return_value=_key_config_trailer_response()) + + with ( + patch.object( + proxy_module, "get_candidates", return_value=[(model_obj, upstream)] + ), + patch.object( + proxy_module, "get_max_cost_for_model", AsyncMock(return_value=1_000) + ), + patch.object( + proxy_module, + "calculate_discounted_max_cost", + AsyncMock(return_value=1_000), + ), + patch.object(proxy_module, "check_token_balance", MagicMock()), + patch.object(proxy_module, "get_bearer_token_key", AsyncMock(return_value=key)), + patch.object( + proxy_module, + "pay_for_request", + AsyncMock(return_value=reservation_snapshot), + ), + patch.object( + proxy_module, "revert_pay_for_request", AsyncMock(return_value=True) + ), + patch("routstr.upstream.ehbp.forward_with_trailer", forward_mock), + patch_proxy_session(session), + ): + await proxy_module.proxy(request, client_path) + + assert forward_mock.await_args is not None + assert ( + forward_mock.await_args.kwargs["url"] + == "https://inference.tinfoil.sh/v1/chat/completions" + )