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" + )