From 98ddd37853887805cf799396d8114f46db017855 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:07:23 +0800 Subject: [PATCH 01/27] 2 weeks --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index dcdf4844..965fe39b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,3 +87,6 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" } + +[tool.uv] +exclude-newer = "2 weeks" \ No newline at end of file From 24b90af6e6b0f4311116dd0f5abf98b36be29017 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:18:53 +0800 Subject: [PATCH 02/27] refactor: move EHBP logic to dedicated module with explicit opt-in - Add supports_ehbp + get_ehbp_forwarding_target hooks to BaseUpstreamProvider, keeping base.py minimal (no large forwarding methods) - Create routstr/upstream/ehbp.py with forward_ehbp_request and forward_ehbp_x_cashu_request helpers, plus max-cost finalization - PPQAIUpstreamProvider: set supports_ehbp = True, implement target to /private/v1/... with X-Private-Model header - proxy.py: filter to EHBP-capable providers, route to ehbp helpers - Fix bearer EHBP payment: reserve upfront, finalize max cost on success - Fix X-Cashu EHBP: refund full token on failure, refund excess on success - Update docs/ehbp-proxy-support.md to reflect new architecture --- docs/ehbp-proxy-support.md | 144 ++++++++++++ routstr/proxy.py | 86 ++++++- routstr/upstream/base.py | 16 ++ routstr/upstream/ehbp.py | 467 +++++++++++++++++++++++++++++++++++++ routstr/upstream/ppqai.py | 16 ++ 5 files changed, 719 insertions(+), 10 deletions(-) create mode 100644 docs/ehbp-proxy-support.md create mode 100644 routstr/upstream/ehbp.py diff --git a/docs/ehbp-proxy-support.md b/docs/ehbp-proxy-support.md new file mode 100644 index 00000000..0cbb6a2d --- /dev/null +++ b/docs/ehbp-proxy-support.md @@ -0,0 +1,144 @@ +# EHBP Proxy Support for Tinfoil Models + +## Problem + +The SDK's `SecureClient.fetch` encrypts request bodies with HPKE (EHBP protocol) +and sends them to the Routstr provider. The Routstr proxy had no EHBP handling: + +1. It tried to `json.loads()` the binary HPKE-sealed body → failed with a 400 +2. The upstream PPQ.AI public endpoint (`/v1/chat/completions`) doesn't speak + EHBP, so the response had no `Ehbp-Response-Nonce` header +3. `SecureClient` threw `Missing Ehbp-Response-Nonce header` because it expects + every response from an EHBP-configured `baseURL` to carry that header + +## Root cause + +PPQ.AI exposes EHBP-aware inference at `/private/`, separate from the public +`/v1/` endpoint. The Routstr proxy was forwarding to `/v1/` (the public +endpoint) instead of `/private/` (the enclave endpoint). The public endpoint +can't decrypt the body, returns a normal HTTP response, and the SDK can't +decrypt it because there's no nonce header. + +The PPQ private-mode proxy (`ppq-private-mode-proxy/lib/proxy.ts`) shows the +correct pattern: `SecureClient` talks to `api.ppq.ai/private/v1/chat/completions`, +which decrypts inside the attested enclave and returns an EHBP-encrypted +response with the `Ehbp-Response-Nonce` header. + +## What was changed + +### `routstr/proxy.py` + +Detects EHBP requests by checking for the `Ehbp-Encapsulated-Key` header (set +by the EHBP transport on every encrypted request). For EHBP requests: + +- Skips JSON body parsing (the body is binary ciphertext, not JSON) +- Reads the model ID from the `X-Routstr-Model` header (set by the SDK) instead + of from `body.model` +- Routes through new `forward_ehbp_request` (bearer auth) and + `forward_ehbp_x_cashu_request` (x-cashu auth) methods +- Skips reactive 400 param correction (can't parse encrypted response body) +- Still charges the user via `pay_for_request` (uses `max_cost_for_model` from + the model registry, not the body) + +### `routstr/upstream/base.py` + +Keeps EHBP as an explicit opt-in provider capability instead of making every +upstream provider appear EHBP-capable: + +- `supports_ehbp = False` by default +- `get_ehbp_forwarding_target(path, model_obj)` raises `NotImplementedError` + unless a provider opts in and returns a provider-specific EHBP target + +The actual EHBP forwarding logic does **not** live in `base.py`. + +### `routstr/upstream/ehbp.py` + +Contains the shared opaque EHBP transport helpers: + +- `EHBPForwardingTarget` — provider-specific target URL plus extra headers +- `forward_ehbp_request()` — forwards the raw encrypted body to an EHBP-capable + provider, streams the encrypted response back untouched, and finalizes bearer + billing at max cost because usage is encrypted +- `forward_ehbp_x_cashu_request()` — redeems the Cashu token, forwards raw, + refunds the full token on upstream failure, and refunds any value above + `max_cost_for_model` on success + +### `routstr/upstream/ppqai.py` + +- Sets `supports_ehbp = True`. +- Implements `get_ehbp_forwarding_target()` to forward to + `https://api.ppq.ai/private/v1/...` — the PPQ.AI enclave endpoint that + understands EHBP and returns the `Ehbp-Response-Nonce` header. +- Adds `X-Private-Model` with the model's `forwarded_model_id` (e.g. + `private/kimi-k2-6`). PPQ.AI's billing layer needs this since it can't + decrypt the body. + +## Why it's done this way + +The proxy is a **blind relay** for EHBP requests. It cannot decrypt the body +(only the attested enclave can), so it must: + +1. Get the model ID from a header, not the body +2. Forward the raw bytes without parsing or transformation +3. Stream the response back without SSE/cost parsing +4. Pass through EHBP protocol headers (`Ehbp-Encapsulated-Key` on request, + `Ehbp-Response-Nonce` on response) + +Cost tracking happens at the proxy level using `max_cost_for_model` from the +model registry. Because EHBP responses are encrypted, Routstr cannot reconcile +against token usage. Bearer requests reserve and then finalize max-cost billing; +X-Cashu requests redeem the token and refund any amount above max cost. + +## End-to-end flow + +``` +SDK Routstr Proxy PPQ.AI /private/ + │ │ │ + │── X-Routstr-Model: tinfoil-kimi-k2-6 ─│ │ + │── Ehbp-Encapsulated-Key: ───────│ │ + │── Authorization: Bearer ──────│ │ + │── body = HPKE-encrypted(kimi-k2-6) ───│ │ + │ │ │ + │ detects Ehbp-Encapsulated-Key │ + │ reads model from X-Routstr-Model │ + │ does billing/routing │ + │ │ │ + │ adds X-Private-Model: private/kimi-k2-6 + │ forwards raw body to /private/v1/... │ + │ │──────────────────────────────▶│ + │ │ enclave decrypts + │ │ runs inference + │ │◀── Ehbp-Response-Nonce ──────│ + │ │◀── encrypted response ────────│ + │ │ │ + │ streams response back untouched │ + │◀── encrypted response ────────────────│ │ + │ │ │ + SecureClient reads nonce, decrypts │ │ + SDK SSE processing sees plaintext │ │ +``` + +## Model ID mapping + +Three parties see three different model IDs: + +| Party | Header/Body | Value | Source | +|---|---|---|---| +| Routstr proxy | `X-Routstr-Model` header | `tinfoil-kimi-k2-6` | SDK sends full caller-facing id | +| PPQ.AI billing | `X-Private-Model` header | `private/kimi-k2-6` | Proxy sends `forwarded_model_id` | +| Tinfoil enclave | `body.model` (encrypted) | `kimi-k2-6` | SDK strips `tinfoil-` prefix before encryption | + +## Not yet tested + +These changes were written without integration testing due to the complexity +of the full stack (SDK + proxy + PPQ.AI enclave + Cashu mint). Needs end-to-end +verification with a real `tinfoil-*` model request. + +Important assumptions to verify: + +- PPQ.AI accepts `/private/v1/...` with `X-Private-Model`. +- PPQ.AI enforces consistency between `X-Private-Model` and the encrypted + `body.model`, otherwise a malicious client could understate + `X-Routstr-Model` for billing. +- SDK behavior on non-2xx proxy-generated errors that do not carry + `Ehbp-Response-Nonce`. diff --git a/routstr/proxy.py b/routstr/proxy.py index 7a74f682..15f12063 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -28,6 +28,7 @@ from .payment.helpers import ( ) from .payment.models import Model from .upstream import BaseUpstreamProvider +from .upstream.ehbp import forward_ehbp_request, forward_ehbp_x_cashu_request from .upstream.helpers import init_upstreams from .upstream.request_correction import correct_request, extract_error_message @@ -183,7 +184,29 @@ async def proxy( is_responses_api = path.startswith("v1/responses") or path.startswith("responses") request_body = await request.body() - request_body_dict = parse_request_body_json(request_body, path) + + # EHBP (Encrypted HTTP Body Protocol) requests carry an Ehbp-Encapsulated-Key + # header and a binary HPKE-sealed body. The proxy cannot parse the body to + # extract the model id, so the SDK sends it in X-Routstr-Model. Forward the + # raw encrypted body to the upstream's /private/ endpoint and stream the + # encrypted response back untouched — the SDK's SecureClient decrypts it. + is_ehbp = "ehbp-encapsulated-key" in headers + if is_ehbp: + request_body_dict = {} + model_id = headers.get("x-routstr-model", "") + if not model_id: + return create_error_response( + "invalid_request", + "EHBP request missing X-Routstr-Model header", + 400, + request=request, + ) + else: + request_body_dict = parse_request_body_json(request_body, path) + if is_responses_api: + model_id = extract_model_from_responses_request(request_body_dict) + else: + model_id = request_body_dict.get("model", "unknown") # /tee/* GET requests (e.g. attestation) don't map to models — just # forward to all enabled upstreams without model/cost/auth lookups. @@ -219,11 +242,6 @@ async def proxy( "upstream_error", "All upstreams failed", 502, request=request ) - if is_responses_api: - model_id = extract_model_from_responses_request(request_body_dict) - else: - model_id = request_body_dict.get("model", "unknown") - model_obj = get_model_instance(model_id) if not model_obj: @@ -240,6 +258,16 @@ async def proxy( request=request, ) + if is_ehbp: + upstreams = [upstream for upstream in upstreams if upstream.supports_ehbp] + if not upstreams: + return create_error_response( + "unsupported_request", + f"No EHBP-capable provider found for model '{model_id}'", + 400, + request=request, + ) + # todo figure out cost calculation since fallback provider is usually not the same price # Use first provider for initial checks/cost calculation # primary_upstream = upstreams[0] @@ -259,7 +287,23 @@ async def proxy( last_error = None for i, upstream in enumerate(upstreams): try: - if is_responses_api: + if is_ehbp: + if not upstream.supports_ehbp: + logger.warning( + "Upstream %s does not support EHBP for model=%s", + upstream.provider_type, + model_id, + ) + continue + return await forward_ehbp_x_cashu_request( + request=request, + x_cashu_token=x_cashu, + path=path, + max_cost_for_model=max_cost_for_model, + model_obj=model_obj, + upstream=upstream, + ) + elif is_responses_api: return await upstream.handle_x_cashu_responses( request, x_cashu, path, max_cost_for_model, model_obj ) @@ -351,7 +395,7 @@ async def proxy( "upstream_error", "All upstreams failed", 502, request=request ) - if request_body_dict: + if is_ehbp or request_body_dict: await pay_for_request(key, max_cost_for_model, session) # Tracks request params already removed in response to upstream rejections, @@ -365,7 +409,29 @@ async def proxy( try: while True: try: - if is_responses_api: + if is_ehbp: + if not upstream.supports_ehbp: + logger.warning( + "Upstream %s does not support EHBP for model=%s", + upstream.provider_type, + model_id, + ) + raise UpstreamError( + f"Provider {upstream.provider_type} does not support EHBP", + status_code=400, + ) + response = await forward_ehbp_request( + request=request, + path=path, + headers=headers, + request_body=request_body, + upstream=upstream, + key=key, + max_cost_for_model=max_cost_for_model, + session=session, + model_obj=model_obj, + ) + elif is_responses_api: response = await upstream.forward_responses_request( request, path, @@ -410,7 +476,7 @@ async def proxy( # When the upstream 400s naming such a param, strip it from the # body and retry the SAME upstream. ``already_stripped`` bounds # this to one retry per distinct param so it always terminates. - if response.status_code == 400: + if response.status_code == 400 and not is_ehbp: correction = correct_request( request_body, extract_error_message(response), diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 1503cc67..19c0d1e5 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -2734,6 +2734,22 @@ class BaseUpstreamProvider: # Don't revert here — proxy.py owns payment revert to avoid double-revert raise UpstreamError("An unexpected server error occurred", status_code=500) + supports_ehbp: bool = False + + def get_ehbp_forwarding_target( + self, path: str, model_obj: Model + ) -> "EHBPForwardingTarget": + """Return the EHBP forwarding target for this provider. + + Providers must explicitly opt in by setting ``supports_ehbp = True`` + and overriding this method. Most upstreams do not accept EHBP-encrypted + request bodies, so the base provider intentionally does not provide a + default endpoint. + """ + raise NotImplementedError( + f"Provider {self.provider_type} does not support EHBP forwarding" + ) + async def forward_responses_request( self, request: Request, diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py new file mode 100644 index 00000000..227f54e1 --- /dev/null +++ b/routstr/upstream/ehbp.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +import json +import math +import time +import traceback +from dataclasses import dataclass, field +from typing import Mapping + +import httpx +from fastapi import BackgroundTasks, Request +from fastapi.responses import Response, StreamingResponse +from sqlalchemy import case +from sqlmodel import col, update + +from ..auth import ROUTSTR_FEE_PERCENT, get_billing_key, payments_logger +from ..core import get_logger +from ..core.db import ( + ApiKey, + AsyncSession, + accumulate_routstr_fee, + store_cashu_transaction, +) +from ..core.exceptions import UpstreamError +from ..payment.helpers import create_error_response +from ..payment.models import Model +from ..wallet import recieve_token, send_token + +logger = get_logger(__name__) + + +@dataclass(frozen=True) +class EHBPForwardingTarget: + """Provider-specific destination for an EHBP opaque request.""" + + url: str + headers: Mapping[str, str] = field(default_factory=dict) + + +async def finalize_ehbp_max_cost_payment( + key: ApiKey, + session: AsyncSession, + max_cost_for_model: int, + model_id: str, +) -> None: + """Finalize an EHBP bearer request by charging the reserved max cost. + + EHBP responses are encrypted, so Routstr cannot inspect token usage. Unlike + normal completion handlers, this intentionally charges the pre-reserved max + cost and releases the reservation. + """ + billing_key = await get_billing_key(key, session) + total_cost_msats = max(0, int(max_cost_for_model)) + now = int(time.time()) + + cleared_reserved_at = case( + (col(ApiKey.reserved_balance) - max_cost_for_model > 0, col(ApiKey.reserved_at)), + else_=None, + ) + safe_reserved = case( + ( + col(ApiKey.reserved_balance) >= max_cost_for_model, + col(ApiKey.reserved_balance) - max_cost_for_model, + ), + else_=0, + ) + + stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == billing_key.hashed_key) + .values( + reserved_balance=safe_reserved, + reserved_at=cleared_reserved_at, + balance=col(ApiKey.balance) - total_cost_msats, + total_spent=col(ApiKey.total_spent) + total_cost_msats, + ) + ) + result = await session.exec(stmt) # type: ignore[call-overload] + + if billing_key.hashed_key != key.hashed_key: + child_safe_reserved = case( + ( + col(ApiKey.reserved_balance) >= max_cost_for_model, + col(ApiKey.reserved_balance) - max_cost_for_model, + ), + else_=0, + ) + child_cleared_reserved_at = case( + ( + col(ApiKey.reserved_balance) - max_cost_for_model > 0, + col(ApiKey.reserved_at), + ), + else_=None, + ) + child_stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == key.hashed_key) + .values( + reserved_balance=child_safe_reserved, + reserved_at=child_cleared_reserved_at, + total_spent=col(ApiKey.total_spent) + total_cost_msats, + ) + ) + await session.exec(child_stmt) # type: ignore[call-overload] + + await session.commit() + + if result.rowcount == 0: + logger.error( + "Failed to finalize EHBP max-cost payment", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "billing_key_hash": billing_key.hashed_key[:8] + "...", + "model": model_id, + "max_cost_for_model": max_cost_for_model, + }, + ) + return + + await session.refresh(billing_key) + if billing_key.hashed_key != key.hashed_key: + await session.refresh(key) + + if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0: + fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100) + try: + await accumulate_routstr_fee(session, fee_msats) + except Exception as e: + logger.warning( + "Failed to accumulate Routstr fee for EHBP request", + extra={"error": str(e), "fee_msats": fee_msats}, + ) + + payments_logger.info( + "FINALIZE", + extra={ + "event": "finalize", + "key_hash": key.hashed_key[:8] + "...", + "billing_key_hash": billing_key.hashed_key[:8] + "...", + "model": model_id, + "cost_reserved": max_cost_for_model, + "cost_charged": total_cost_msats, + "input_tokens": 0, + "output_tokens": 0, + "balance": billing_key.balance, + "reserved_balance": billing_key.reserved_balance, + "total_spent": billing_key.total_spent, + "finalize_type": "ehbp_max_cost", + "finalized_at": now, + }, + ) + + +async def send_cashu_refund( + amount: int, + unit: str, + mint: str | None = None, + request_id: str | None = None, +) -> str: + """Create a Cashu refund token and record the outgoing transaction.""" + refund_token = await send_token(amount, unit=unit, mint_url=mint) + try: + await store_cashu_transaction( + token=refund_token, + amount=amount, + unit=unit, + mint_url=mint, + typ="out", + request_id=request_id, + ) + except Exception: + pass + return refund_token + + +def _msats_to_unit_amount(msats: int, unit: str) -> int: + if unit == "msat": + return msats + if unit == "sat": + return (msats + 999) // 1000 + raise ValueError(f"Invalid unit: {unit}") + + +async def forward_ehbp_request( + *, + request: Request, + path: str, + headers: dict, + request_body: bytes | None, + upstream: object, + key: ApiKey, + max_cost_for_model: int, + session: AsyncSession, + model_obj: Model, +) -> Response | StreamingResponse: + """Forward an EHBP bearer-auth request and finalize max-cost billing.""" + target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] + upstream_headers = {**headers, **dict(target.headers)} + upstream_headers.pop("x-routstr-model", None) + upstream_headers.pop("X-Routstr-Model", None) + + provider_type = getattr(upstream, "provider_type", "unknown") + logger.debug( + "Forwarding EHBP request to upstream", + extra={ + "url": target.url, + "method": request.method, + "path": path, + "model": model_obj.id, + "provider": provider_type, + "key_hash": key.hashed_key[:8] + "...", + }, + ) + + client = httpx.AsyncClient( + transport=httpx.AsyncHTTPTransport(retries=1), + timeout=None, + ) + + try: + response = await client.send( + client.build_request( + request.method, + target.url, + headers=upstream_headers, + content=request_body, + params=upstream.prepare_params(path, request.query_params), # type: ignore[attr-defined] + ), + stream=True, + ) + + if response.status_code != 200: + body_bytes = await response.aread() + body_preview = body_bytes.decode("utf-8", errors="ignore").strip()[:500] + logger.error( + "EHBP upstream %s returned %s for model=%s path=%s: %s", + provider_type, + response.status_code, + model_obj.id, + path, + body_preview or "", + extra={ + "provider": provider_type, + "model": model_obj.id, + "status_code": response.status_code, + "path": path, + "body_preview": body_preview, + }, + ) + await response.aclose() + await client.aclose() + raise UpstreamError( + f"EHBP upstream {provider_type} returned {response.status_code} " + f"for model {model_obj.id}: {body_preview[:200] or ''}", + status_code=response.status_code, + ) + + await finalize_ehbp_max_cost_payment( + key, session, max_cost_for_model, model_obj.id + ) + + background_tasks = BackgroundTasks() + background_tasks.add_task(response.aclose) + background_tasks.add_task(client.aclose) + + return StreamingResponse( + response.aiter_bytes(), + status_code=response.status_code, + headers=dict(response.headers), + background=background_tasks, + ) + except UpstreamError: + await client.aclose() + raise + except httpx.RequestError as exc: + await client.aclose() + raise UpstreamError( + f"Error connecting to EHBP upstream: {type(exc).__name__}", + status_code=502, + ) from exc + except Exception as exc: + tb = traceback.format_exc() + logger.error( + "Unexpected error in EHBP upstream forwarding", + extra={ + "error": str(exc), + "error_type": type(exc).__name__, + "method": request.method, + "url": target.url, + "path": path, + "traceback": tb, + }, + ) + await client.aclose() + raise UpstreamError("An unexpected server error occurred", status_code=500) + + +async def forward_ehbp_x_cashu_request( + *, + request: Request, + x_cashu_token: str, + path: str, + max_cost_for_model: int, + model_obj: Model, + upstream: object, +) -> Response | StreamingResponse: + """Redeem X-Cashu, forward EHBP opaquely, and refund unspent value. + + Since the response is encrypted, usage cannot be inspected. Successful EHBP + X-Cashu requests are charged at max_cost_for_model and any excess token + value is refunded. + """ + request_id = getattr(request.state, "request_id", None) + amount = 0 + unit = "msat" + mint: str | None = None + redeemed = False + + try: + amount, unit, mint = await recieve_token(x_cashu_token) + redeemed = True + try: + await store_cashu_transaction( + token=x_cashu_token, + amount=amount, + unit=unit, + mint_url=mint, + typ="in", + request_id=request_id, + collected=True, + ) + except Exception: + pass + + headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined] + target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] + upstream_headers = {**headers, **dict(target.headers)} + upstream_headers.pop("x-routstr-model", None) + upstream_headers.pop("X-Routstr-Model", None) + request_body = await request.body() + + client = httpx.AsyncClient( + transport=httpx.AsyncHTTPTransport(retries=1), + timeout=None, + ) + + try: + response = await client.send( + client.build_request( + request.method, + target.url, + headers=upstream_headers, + content=request_body, + params=upstream.prepare_params(path, request.query_params), # type: ignore[attr-defined] + ), + stream=True, + ) + + if response.status_code != 200: + await response.aclose() + await client.aclose() + refund_token = await send_cashu_refund(amount, unit, mint, request_id) + error_response = Response( + content=json.dumps( + { + "error": { + "message": "Error forwarding EHBP request to upstream", + "type": "upstream_error", + "code": response.status_code, + "refund_token": refund_token, + } + } + ), + status_code=response.status_code, + media_type="application/json", + ) + error_response.headers["X-Cashu"] = refund_token + return error_response + + refund_amount = amount - _msats_to_unit_amount(max_cost_for_model, unit) + response_headers = dict(response.headers) + if refund_amount > 0: + response_headers["X-Cashu"] = await send_cashu_refund( + refund_amount, unit, mint, request_id + ) + + background_tasks = BackgroundTasks() + background_tasks.add_task(response.aclose) + background_tasks.add_task(client.aclose) + + return StreamingResponse( + response.aiter_bytes(), + status_code=response.status_code, + headers=response_headers, + background=background_tasks, + ) + except Exception: + await client.aclose() + raise + + except Exception as e: + error_message = str(e) + logger.error( + "EHBP X-Cashu request failed", + extra={ + "error": error_message, + "error_type": type(e).__name__, + "path": path, + "method": request.method, + "redeemed": redeemed, + }, + ) + + if redeemed and amount > 0: + try: + refund_token = await send_cashu_refund(amount, unit, mint, request_id) + error_response = create_error_response( + "upstream_error", + "EHBP request failed after token redemption; refunded token", + 502, + request=request, + ) + error_response.headers["X-Cashu"] = refund_token + return error_response + except Exception as refund_error: + logger.error( + "Failed to refund EHBP X-Cashu token after error", + extra={ + "error": str(refund_error), + "original_error": error_message, + }, + ) + + if "already spent" in error_message.lower(): + return create_error_response( + "token_already_spent", + "The provided CASHU token has already been spent", + 400, + request=request, + token=x_cashu_token, + ) + + if "invalid token" in error_message.lower(): + return create_error_response( + "invalid_token", + "The provided CASHU token is invalid", + 400, + request=request, + token=x_cashu_token, + ) + + if "mint error" in error_message.lower(): + return create_error_response( + "mint_error", + f"CASHU mint error: {error_message}", + 422, + request=request, + token=x_cashu_token, + ) + + return create_error_response( + "cashu_error" if not redeemed else "upstream_error", + f"EHBP X-Cashu request failed: {error_message}", + 400 if not redeemed else 502, + request=request, + token=x_cashu_token if not redeemed else None, + ) diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py index 48ca9600..8cf1cc62 100644 --- a/routstr/upstream/ppqai.py +++ b/routstr/upstream/ppqai.py @@ -8,6 +8,7 @@ from pydantic.v1 import BaseModel, Field from ..core.logging import get_logger from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models from .base import BaseUpstreamProvider, TopupData +from .ehbp import EHBPForwardingTarget if TYPE_CHECKING: from ..core.db import UpstreamProviderRow @@ -39,6 +40,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): default_base_url = "https://api.ppq.ai" platform_url = "https://ppq.ai/api-docs" IGNORED_MODEL_IDS: list[str] = ["auto"] + supports_ehbp = True def __init__(self, api_key: str, provider_fee: float = 1.0): super().__init__( @@ -70,6 +72,20 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): def transform_model_name(self, model_id: str) -> str: return model_id + def get_ehbp_forwarding_target( + self, path: str, model_obj: Model + ) -> EHBPForwardingTarget: + """Return the PPQ.AI private enclave target for EHBP requests. + + PPQ.AI exposes EHBP-aware inference under /private/v1/... separate + from the public /v1/... endpoint. The encrypted body remains opaque to + Routstr, so PPQ.AI also needs X-Private-Model for routing/billing. + """ + return EHBPForwardingTarget( + url=f"{self.base_url.rstrip('/')}/private/{path.lstrip('/')}", + headers={"X-Private-Model": model_obj.forwarded_model_id or model_obj.id}, + ) + @classmethod async def create_account_static(cls) -> dict[str, object]: """Create a new PPQ.AI account without requiring an instance. From 2e350e082b52459aeb43ff1857a605a3e32df1a2 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:16:01 +0800 Subject: [PATCH 03/27] feat: add Tinfoil direct blind-upstream integration Add TinfoilUpstreamProvider that uses inference.tinfoil.sh as a direct EHBP upstream. Routstr acts as a blind relay: it forwards the opaque encrypted body to the Tinfoil enclave without ever seeing plaintext, and bills from the X-Tinfoil-Usage-Metrics response header. - New routstr/upstream/tinfoil.py: fetches models from public GET /v1/models, parses Tinfoil pricing into standard Model/Pricing schema, supports_ehbp=True, proxies /attestation to atc.tinfoil.sh - Updated routstr/upstream/ehbp.py: - parse_tinfoil_usage_metrics() parses prompt=N,completion=N header - _resolve_ehbp_target_url() honors X-Tinfoil-Enclave-Url from SDK - _strip_proxy_headers() removes proxy-only headers before forwarding - _compute_ehbp_actual_cost() converts usage to msats via calculate_cost - forward_ehbp_request() finalizes with exact token cost when usage header is present (non-streaming), falls back to max-cost otherwise - forward_ehbp_x_cashu_request() computes refund from actual cost when usage is available - Updated routstr/proxy.py: forward /attestation and /.well-known/ paths without model/cost/auth lookups - Updated routstr/upstream/__init__.py and helpers.py: register and auto-seed Tinfoil provider from TINFOIL_API_KEY env var - Updated .env.example with TINFOIL_API_KEY - 22 new unit tests in tests/unit/test_tinfoil_integration.py - Updated docs/tinfoil-direct-integration.md and docs/ehbp-proxy-support.md with implementation status and billing behavior table --- .env.example | 3 + docs/ehbp-proxy-support.md | 14 + docs/tinfoil-direct-integration.md | 442 +++++++++++++++++++++++++ routstr/proxy.py | 11 +- routstr/upstream/__init__.py | 2 + routstr/upstream/ehbp.py | 207 ++++++++++-- routstr/upstream/helpers.py | 1 + routstr/upstream/tinfoil.py | 218 ++++++++++++ tests/unit/test_tinfoil_integration.py | 302 +++++++++++++++++ 9 files changed, 1178 insertions(+), 22 deletions(-) create mode 100644 docs/tinfoil-direct-integration.md create mode 100644 routstr/upstream/tinfoil.py create mode 100644 tests/unit/test_tinfoil_integration.py diff --git a/.env.example b/.env.example index d4c768d5..81138cc6 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ UPSTREAM_BASE_URL=https://api.openai.com/v1 UPSTREAM_API_KEY=your-upstream-api-key +# Tinfoil (confidential inference enclaves, EHBP) +# TINFOIL_API_KEY=your-tinfoil-api-key + # ADMIN_PASSWORD=secure-admin-password # Database diff --git a/docs/ehbp-proxy-support.md b/docs/ehbp-proxy-support.md index 0cbb6a2d..05b5aad3 100644 --- a/docs/ehbp-proxy-support.md +++ b/docs/ehbp-proxy-support.md @@ -128,6 +128,20 @@ Three parties see three different model IDs: | PPQ.AI billing | `X-Private-Model` header | `private/kimi-k2-6` | Proxy sends `forwarded_model_id` | | Tinfoil enclave | `body.model` (encrypted) | `kimi-k2-6` | SDK strips `tinfoil-` prefix before encryption | +## Implementation status + +A dedicated `TinfoilUpstreamProvider` (`routstr/upstream/tinfoil.py`) now +implements the direct blind-upstream pattern described above. The shared EHBP +helpers in `routstr/upstream/ehbp.py` were extended to: + +- Request usage metrics via `X-Tinfoil-Request-Usage-Metrics: true`. +- Parse `X-Tinfoil-Usage-Metrics` from the response header (non-streaming). +- Override the forwarding URL with `X-Tinfoil-Enclave-Url` when the SDK sends it. +- Finalize bearer billing with actual token cost via `adjust_payment_for_tokens`. +- Compute X-Cashu refunds from actual cost instead of max cost. + +See `docs/tinfoil-direct-integration.md` for the full implementation notes. + ## Not yet tested These changes were written without integration testing due to the complexity diff --git a/docs/tinfoil-direct-integration.md b/docs/tinfoil-direct-integration.md new file mode 100644 index 00000000..9660d224 --- /dev/null +++ b/docs/tinfoil-direct-integration.md @@ -0,0 +1,442 @@ +# Tinfoil / PPQ Private-Mode Integration Notes + +This document summarizes the current options for integrating Tinfoil/PPQ private models with Routstr, based on the EHBP work in this branch and local testing against `ppq-private-mode-proxy`. + +## Background + +PPQ private models run behind a Tinfoil/EHBP flow: + +- Request bodies are HPKE-encrypted by a Tinfoil client. +- The PPQ `/private/` endpoint routes ciphertext to the attested enclave. +- The enclave decrypts, runs inference, and returns an encrypted response. +- The caller's Tinfoil client decrypts the response locally. + +The PPQ private-mode proxy (`~/projects/ppq-private-mode-proxy`) uses this pattern with the JavaScript `tinfoil` SDK: + +```ts +import { SecureClient } from "tinfoil"; + +const apiBase = "https://api.ppq.ai"; + +const client = new SecureClient({ + baseURL: `${apiBase}/private/`, + attestationBundleURL: `${apiBase}/private`, + transport: "ehbp", +}); + +await client.ready(); + +const response = await client.fetch(`${apiBase}/private/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.PPQ_API_KEY}`, + "X-Private-Model": "private/gpt-oss-120b", + "x-query-source": "api", + }, + body: JSON.stringify({ + model: "gpt-oss-120b", // enclave-internal model id + messages: [{ role: "user", content: "Hello" }], + }), +}); + +const json = await response.json(); +console.log(json.usage); +``` + +`X-Private-Model` carries the PPQ-facing private model id, while the encrypted JSON body uses the enclave-internal model id without the `private/` prefix. + +## PPQ private model pricing + +PPQ exposes private model pricing through: + +```text +GET https://api.ppq.ai/v1/models?type=all +``` + +Filter models whose IDs start with `private/`. + +Example private pricing observed: + +| Model | Input USD / 1M tokens | Output USD / 1M tokens | +|---|---:|---:| +| `private/gpt-oss-120b` | `0.79125` | `1.31875` | +| `private/llama3-3-70b` | `1.84625` | `2.90125` | +| `private/qwen3-vl-30b` | `1.31875` | `4.22` | +| `private/glm-5-2` | `1.5825` | `5.53875` | +| `private/gemma4-31b` | `0.47475` | `1.055` | +| `private/kimi-k2-6` | `1.5825` | `5.53875` | + +The `ppq-private-mode-proxy` commit `ba984214793d3bca0f7d046b6955d42abc1c6843` changed OpenClaw display metadata to align with a 5% API margin. The live PPQ model endpoint returns the more precise rates above, which already include that margin. + +Actual PPQ private billing is: + +```text +price_usd = + input_tokens * input_per_1M_tokens / 1_000_000 ++ output_tokens * output_per_1M_tokens / 1_000_000 +``` + +A test request to `private/gpt-oss-120b` produced: + +```json +{ + "input_count": 74, + "output_count": 3, + "price_in_usd": 0.00006250875 +} +``` + +which exactly matches: + +```text +74 * 0.79125 / 1_000_000 + 3 * 1.31875 / 1_000_000 += 0.00006250875 USD +``` + +## Integration architectures + +There are three materially different ways Routstr could integrate Tinfoil/PPQ private inference. + +## Option A: User integrates Tinfoil directly + +```text +User app / SDK + -> Tinfoil SecureClient / TinfoilAI + -> EHBP-encrypted request + -> Tinfoil/PPQ private enclave +``` + +Properties: + +- Best privacy for the user. +- Routstr is not in the request path. +- User's client encrypts requests and decrypts responses. +- Usage is visible to the user's app after decryption. +- Billing is handled directly by Tinfoil/PPQ. + +Example with Tinfoil's OpenAI-compatible client: + +```ts +import { TinfoilAI } from "tinfoil"; + +const client = new TinfoilAI({ + apiKey: process.env.TINFOIL_API_KEY, + transport: "ehbp", +}); + +const res = await client.chat.completions.create({ + model: "llama3-3-70b", + messages: [{ role: "user", content: "Hello" }], +}); + +console.log(res.choices[0].message.content); +console.log(res.usage); +``` + +This is not a Routstr marketplace flow unless Routstr only acts as discovery/UI around direct Tinfoil/PPQ usage. + +## Option B: Routstr integrates Tinfoil as an upstream client + +```text +User -> Routstr plaintext request + -> Routstr Tinfoil SecureClient encrypts to PPQ/Tinfoil + -> PPQ private enclave + -> Routstr receives decrypted response + -> Routstr bills from decrypted usage + -> Routstr returns plaintext response to user +``` + +Properties: + +- Easier exact billing. +- Routstr can read the decrypted OpenAI response and `usage` object. +- Routstr can charge exact PPQ token pricing. +- Privacy is different: the user sends plaintext to Routstr, and Routstr sees prompts/responses. +- End-to-end encryption is only Routstr-to-enclave, not user-to-enclave. + +This should be considered a separate product/provider mode, not the same as an end-to-end private relay. + +Practical implementation options: + +1. Run a Node sidecar that uses the `tinfoil` npm package and expose it as a local HTTP upstream to Routstr. +2. Port EHBP client behavior to Python. +3. Reuse or adapt `ppq-private-mode-proxy` as a local upstream. + +A Node sidecar is probably the quickest implementation path because `ppq-private-mode-proxy` already demonstrates the full flow. + +## Option C: Routstr uses Tinfoil as a direct blind upstream + +This is the current branch's design intent and is likely the best fit if Tinfoil/PPQ exposes usage metadata headers: + +```text +User Tinfoil SecureClient + -> encrypted request body + -> Routstr proxy + -> Tinfoil/PPQ private API + with X-Tinfoil-Request-Usage-Metrics: true + -> PPQ private enclave + <- encrypted response + plus X-Tinfoil-Usage-Metrics / cost headers + <- Routstr proxy + -> user decrypts response +``` + +In this mode Routstr is still a normal upstream proxy from the user's point of view, but the upstream is Tinfoil/PPQ private inference and the body remains opaque to Routstr. + +Properties: + +- Strongest privacy with Routstr in the path. +- Routstr never sees plaintext prompt or plaintext response. +- Routstr can authenticate and route based on plaintext headers. +- Routstr should request Tinfoil usage metadata by adding `X-Tinfoil-Request-Usage-Metrics: true` to the upstream request. +- Tinfoil documents `X-Tinfoil-Usage-Metrics` as an upstream response header for non-streaming requests. +- For streaming requests, Tinfoil documents `X-Tinfoil-Usage-Metrics` as an HTTP trailer available only after the response body completes. +- If Tinfoil/PPQ returns `X-Tinfoil-Usage-Metrics` or a cost header on the encrypted response, Routstr can bill exactly without decrypting the body. +- If usage is only present inside the encrypted response body, Routstr still cannot read it and exact billing is not possible without a separate metadata path. + +This is the only architecture that preserves end-to-end encryption from the user to the PPQ/Tinfoil enclave while still letting Routstr mediate payment. The key requirement is that usage/cost metadata must be returned outside the encrypted body, ideally as a response header available before body streaming begins. + +## Current Routstr problem + +The current EHBP implementation charges successful EHBP requests at `max_cost_for_model` because Routstr cannot decrypt the response body: + +```text +successful EHBP request -> charge full reserved max cost +``` + +That is incorrect for PPQ private models. Max cost should be only a reservation/solvency ceiling. Final charge should use actual PPQ private token pricing. + +Desired behavior: + +```text +reserve max cost +forward encrypted request +obtain actual usage/cost metadata +finalize actual cost +refund/release the difference +``` + +## Usage/cost metadata requirement + +For blind-relay exact billing, PPQ should return one of the following outside the encrypted response body: + +```http +X-PPQ-Cost-USD: 0.00006250875 +``` + +or: + +```http +X-Private-Usage-Metrics: input=74,output=3 +``` + +or: + +```http +X-Tinfoil-Usage-Metrics: prompt=74,completion=3,total=77 +``` + +Tinfoil proxy documentation references a usage-metrics flow where a proxy can request usage via: + +```http +X-Tinfoil-Request-Usage-Metrics: true +``` + +and read usage from: + +```http +X-Tinfoil-Usage-Metrics +``` + +Docs/example references: + +- https://docs.tinfoil.sh/guides/proxy-server +- https://github.com/tinfoilsh/encrypted-request-proxy-example + +During local PPQ testing, PPQ responses included this CORS exposure header: + +```http +Access-Control-Expose-Headers: Ehbp-Response-Nonce, X-Private-Usage-Metrics, X-Encrypted-Usage-Metrics, X-Tinfoil-Usage-Metrics +``` + +However, the actual tested non-streaming response did not include any of these usage headers, even when `X-Tinfoil-Request-Usage-Metrics: true` was sent. + +The decrypted body did include normal OpenAI usage, but only the decrypting Tinfoil client can see that body. + +## Query-history fallback + +PPQ's query history endpoint exposes actual usage and cost: + +```text +GET https://api.ppq.ai/queries/history?page=1&page_count=... +``` + +A record includes: + +```json +{ + "timestamp": "...", + "model": "private/gpt-oss-120b", + "input_count": 74, + "output_count": 3, + "price_in_usd": 0.00006250875, + "query_type": "chat_completion", + "query_source": "api" +} +``` + +This could be used as a fallback, but it is less robust than response headers/trailers because matching a request to a history row can be race-prone under concurrency. It would need a reliable request identifier or metadata field that PPQ stores in history. + +## Recommended Routstr direction + +Use Tinfoil/PPQ as a direct blind upstream and have Routstr explicitly request usage metadata: + +```text +User encrypts body +Routstr reserves max cost +Routstr forwards encrypted body to Tinfoil/PPQ /private/ +Routstr includes X-Tinfoil-Request-Usage-Metrics: true +Tinfoil/PPQ returns X-Tinfoil-Usage-Metrics as a response header for non-streaming, +or as an HTTP trailer after the body completes for streaming +Routstr finalizes exact charge +User decrypts encrypted response +``` + +This preserves both: + +- privacy: Routstr cannot read prompts/responses; +- exact billing: Routstr can charge actual PPQ private model cost, assuming Tinfoil/PPQ returns usage/cost metadata outside the encrypted body. + +Implementation steps: + +1. Update PPQ model fetching to include private models: + + ```text + GET https://api.ppq.ai/v1/models?type=all + ``` + +2. Register `private/*` models and any Routstr-facing aliases with correct `forwarded_model_id`. + +3. Keep max-cost reservation for bearer keys and X-Cashu solvency checks. + +4. Add parsing support for possible usage/cost headers: + + ```http + X-PPQ-Cost-USD + X-Private-Usage-Metrics + X-Tinfoil-Usage-Metrics + X-Encrypted-Usage-Metrics + ``` + +5. Finalize by actual cost instead of max cost: + + ```text + actual_msats = ceil((actual_usd / sats_usd_price()) * 1000) + actual_msats = max(actual_msats, settings.min_request_msat) + actual_msats = min(actual_msats, reserved_msats) + ``` + + or, if only token counts are available: + + ```text + actual_usd = + input_tokens * input_per_1M_tokens / 1_000_000 + + output_tokens * output_per_1M_tokens / 1_000_000 + ``` + +6. If usage/cost metadata is missing, choose an explicit policy: + + - fail closed and refund/revert; + - query PPQ history as a fallback; + - fallback to max-cost billing only if explicitly configured and clearly disclosed. + +Silent max-cost billing should not be the default for PPQ private requests. + +## X-Cashu consideration + +For bearer-auth requests, finalization can happen after the response stream completes if usage is delivered as a trailer. + +For `X-Cashu`, Routstr needs to return the refund token in the response headers. If usage/cost is only available after consuming the encrypted response stream, Routstr may need to buffer EHBP responses before sending them to the client so it can compute the refund amount first. + +Possible approaches: + +1. Prefer a non-trailer response header with actual cost, available before streaming body starts. +2. Buffer EHBP X-Cashu responses and then return `X-Cashu` refund. +3. Introduce a later/refund-claim mechanism, which would be a larger protocol change. + +## Summary + +- PPQ private models are billed per actual input/output tokens. +- Private model rates are available from `GET /v1/models?type=all`. +- Current Routstr EHBP billing at max cost is wrong for PPQ private models. +- Direct Tinfoil integration inside Routstr would enable exact usage billing but would make Routstr see plaintext. +- A blind EHBP relay preserves privacy but requires PPQ/Tinfoil to expose usage/cost in plaintext headers/trailers. +- The preferred solution is to keep Routstr blind and have PPQ return billing metadata outside the encrypted body. + +## Implementation status + +Direct Tinfoil upstream integration is implemented in `routstr/upstream/tinfoil.py` +and `routstr/upstream/ehbp.py`. + +### What was built + +- `TinfoilUpstreamProvider` (`provider_type = "tinfoil"`): + - Base URL: `https://inference.tinfoil.sh` + - Fetches models from the public `GET /v1/models` endpoint (no auth needed). + - Parses Tinfoil's pricing (`inputTokenPricePer1M`, `outputTokenPricePer1M`, + `requestPrice`) into the standard `Model`/`Pricing` schema. + - `supports_ehbp = True` — acts as a blind EHBP relay. + - `get_ehbp_forwarding_target()` returns a target that includes + `X-Tinfoil-Request-Usage-Metrics: true`. + - `forward_get_request()` proxies `/attestation` to `https://atc.tinfoil.sh/attestation` + so the SDK can fetch attestation bundles through Routstr. + - Registered in `routstr/upstream/__init__.py` and seeded from + `TINFOIL_API_KEY` env var. + +- `routstr/upstream/ehbp.py`: + - `parse_tinfoil_usage_metrics()` parses `prompt=N,completion=N[,total=N]` + into an OpenAI-style usage dict. + - `_resolve_ehbp_target_url()` overrides the forwarding URL with + `X-Tinfoil-Enclave-Url` when the SDK sends it. + - `_strip_proxy_headers()` removes `X-Routstr-Model`, + `X-Tinfoil-Enclave-Url`, and `X-Tinfoil-Request-Usage-Metrics` before + forwarding to the enclave. + - `_compute_ehbp_actual_cost()` converts the usage header into msats via + `calculate_cost()`, clamped to `[min_request_msat, max_cost_for_model]`. + - `forward_ehbp_request()` (bearer auth): if `X-Tinfoil-Usage-Metrics` is + present in the response header, finalizes with `adjust_payment_for_tokens()` + for exact billing; otherwise falls back to max-cost. + - `forward_ehbp_x_cashu_request()`: if usage is available, computes the + refund from actual cost instead of max cost. + +- `routstr/proxy.py`: `/attestation` and `/.well-known/` paths are forwarded + to all enabled upstreams without model/cost/auth lookups. + +### Billing behavior + +| Request shape | Usage source | Billing | +|---|---|---| +| Bearer, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Exact token cost via `adjust_payment_for_tokens` | +| Bearer, streaming | HTTP trailer (not available before body) | Max-cost fallback | +| X-Cashu, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Refund = `redeemed - actual_cost` | +| X-Cashu, streaming | HTTP trailer | Refund = `redeemed - max_cost` | + +### Setup + +```bash +TINFOIL_API_KEY=your-tinfoil-api-key +``` + +The provider is auto-seeded on first startup. + +### What still needs verification + +- End-to-end test with a real Tinfoil SDK client against a Routstr node with + `TINFOIL_API_KEY` set. +- Streaming requests: usage is delivered as an HTTP trailer. Currently the + bearer path finalizes max-cost before streaming begins. Supporting streaming + usage would require buffering the response (for X-Cashu) or a deferred + finalization (for bearer). +- Whether Tinfoil's `/v1/responses` endpoint also returns usage metrics + headers. diff --git a/routstr/proxy.py b/routstr/proxy.py index 15f12063..f6977cf0 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -166,6 +166,8 @@ _API_PATH_PREFIXES = ( "moderations", "providers", "tee/", + "attestation", + ".well-known/", ) @@ -208,9 +210,12 @@ async def proxy( else: model_id = request_body_dict.get("model", "unknown") - # /tee/* GET requests (e.g. attestation) don't map to models — just - # forward to all enabled upstreams without model/cost/auth lookups. - if request.method == "GET" and path.startswith("tee/"): + # /tee/* and /attestation GET requests (e.g. Tinfoil attestation bundle) + # don't map to models — just forward to all enabled upstreams without + # model/cost/auth lookups. + if request.method == "GET" and ( + path.startswith("tee/") or path.startswith("attestation") + ): all_upstreams = _upstreams last_error_response = None for i, upstream in enumerate(all_upstreams): diff --git a/routstr/upstream/__init__.py b/routstr/upstream/__init__.py index c9156b10..35e49bcf 100644 --- a/routstr/upstream/__init__.py +++ b/routstr/upstream/__init__.py @@ -11,6 +11,7 @@ from .openrouter import OpenRouterUpstreamProvider from .perplexity import PerplexityUpstreamProvider from .ppqai import PPQAIUpstreamProvider from .routstr import RoutstrUpstreamProvider +from .tinfoil import TinfoilUpstreamProvider from .xai import XAIUpstreamProvider upstream_provider_classes: list[type[BaseUpstreamProvider]] = [ @@ -26,6 +27,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [ PerplexityUpstreamProvider, PPQAIUpstreamProvider, RoutstrUpstreamProvider, + TinfoilUpstreamProvider, XAIUpstreamProvider, ] """List of all upstream classes""" diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 227f54e1..8f78b6de 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -13,7 +13,12 @@ from fastapi.responses import Response, StreamingResponse from sqlalchemy import case from sqlmodel import col, update -from ..auth import ROUTSTR_FEE_PERCENT, get_billing_key, payments_logger +from ..auth import ( + ROUTSTR_FEE_PERCENT, + adjust_payment_for_tokens, + get_billing_key, + payments_logger, +) from ..core import get_logger from ..core.db import ( ApiKey, @@ -22,12 +27,139 @@ from ..core.db import ( store_cashu_transaction, ) from ..core.exceptions import UpstreamError +from ..core.settings import settings +from ..payment.cost_calculation import ( + CostData, + MaxCostData, + calculate_cost, +) from ..payment.helpers import create_error_response from ..payment.models import Model from ..wallet import recieve_token, send_token logger = get_logger(__name__) +# Headers that the Tinfoil SDK sends to tell the proxy where to forward the +# encrypted request, and the request/response usage-metrics pair. +_ENCLAVE_URL_HEADER = "X-Tinfoil-Enclave-Url" +_REQUEST_USAGE_HEADER = "X-Tinfoil-Request-Usage-Metrics" +_RESPONSE_USAGE_HEADER = "X-Tinfoil-Usage-Metrics" + +# Headers that must not be forwarded to the upstream enclave. +_PROXY_ONLY_HEADERS = { + "x-routstr-model", + "x-tinfoil-enclave-url", + "x-tinfoil-request-usage-metrics", +} + + +def parse_tinfoil_usage_metrics(header_value: str | None) -> dict | None: + """Parse ``X-Tinfoil-Usage-Metrics`` into an OpenAI-style usage dict. + + The header format is ``prompt=,completion=,total=``. Returns a dict + like ``{"prompt_tokens": n, "completion_tokens": n}`` suitable for + :func:`calculate_cost`, or ``None`` when the header is absent or malformed. + """ + if not header_value: + return None + parts: dict[str, int] = {} + for item in header_value.split(","): + key, sep, value = item.partition("=") + if not sep: + continue + try: + parts[key.strip()] = int(value.strip()) + except (ValueError, TypeError): + continue + prompt = parts.get("prompt") + completion = parts.get("completion") + if prompt is not None and completion is not None: + result: dict[str, int] = { + "prompt_tokens": prompt, + "completion_tokens": completion, + } + if "total" in parts: + result["total_tokens"] = parts["total"] + return result + return None + + +def _resolve_ehbp_target_url( + target_url: str, path: str, headers: Mapping[str, str] +) -> str: + """Override the forwarding URL with ``X-Tinfoil-Enclave-Url`` if present. + + When the Tinfoil SDK is configured with a proxy ``baseURL``, it sends the + actual enclave URL in ``X-Tinfoil-Enclave-Url``. The proxy must forward to + that URL, not to its own default, so the encrypted payload reaches the same + enclave the client verified. + """ + enclave_url = ( + headers.get(_ENCLAVE_URL_HEADER) + or headers.get(_ENCLAVE_URL_HEADER.lower()) + or headers.get(_ENCLAVE_URL_HEADER.upper()) + ) + if enclave_url: + return f"{enclave_url.rstrip('/')}/{path.lstrip('/')}" + return target_url + + +def _strip_proxy_headers(headers: dict[str, str]) -> dict[str, str]: + """Remove proxy-routing headers that must not reach the upstream enclave.""" + clean = {} + for key, value in headers.items(): + if key.lower() not in _PROXY_ONLY_HEADERS: + clean[key] = value + return clean + + +async def _compute_ehbp_actual_cost( + usage_header: str | None, + model_obj: Model, + max_cost_for_model: int, +) -> int: + """Compute the actual cost in msats from Tinfoil usage metrics. + + Falls back to ``max_cost_for_model`` when usage is absent (streaming) or + cannot be priced. The result is clamped to ``[min_request_msat, + max_cost_for_model]`` so the refund never exceeds the reservation and is + never zero. + """ + usage_dict = parse_tinfoil_usage_metrics(usage_header) + if usage_dict is None: + return max_cost_for_model + + try: + cost = await calculate_cost( + {"model": model_obj.id, "usage": usage_dict}, + max_cost_for_model, + ) + except Exception as e: + logger.warning( + "EHBP usage cost calculation failed, falling back to max cost", + extra={ + "model": model_obj.id, + "error": str(e), + "usage": usage_dict, + }, + ) + return max_cost_for_model + + if isinstance(cost, MaxCostData): + return max_cost_for_model + if isinstance(cost, CostData): + actual = max(int(cost.total_msats), int(settings.min_request_msat)) + return min(actual, max_cost_for_model) + # CostDataError + logger.warning( + "EHBP usage cost calculation error, falling back to max cost", + extra={ + "model": model_obj.id, + "error": getattr(cost, "message", str(cost)), + }, + ) + return max_cost_for_model + @dataclass(frozen=True) class EHBPForwardingTarget: @@ -193,17 +325,24 @@ async def forward_ehbp_request( session: AsyncSession, model_obj: Model, ) -> Response | StreamingResponse: - """Forward an EHBP bearer-auth request and finalize max-cost billing.""" + """Forward an EHBP bearer-auth request and finalize billing. + + Sends ``X-Tinfoil-Request-Usage-Metrics: true`` so the enclave returns token + counts in the ``X-Tinfoil-Usage-Metrics`` response header (non-streaming) or + trailer (streaming). When usage is available in the response header, + billing is finalized to the actual token cost via + :func:`adjust_payment_for_tokens`. When usage is not available (streaming + or unsupported upstream), billing falls back to max-cost. + """ target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] - upstream_headers = {**headers, **dict(target.headers)} - upstream_headers.pop("x-routstr-model", None) - upstream_headers.pop("X-Routstr-Model", None) + target_url = _resolve_ehbp_target_url(target.url, path, headers) + upstream_headers = _strip_proxy_headers({**headers, **dict(target.headers)}) provider_type = getattr(upstream, "provider_type", "unknown") logger.debug( "Forwarding EHBP request to upstream", extra={ - "url": target.url, + "url": target_url, "method": request.method, "path": path, "model": model_obj.id, @@ -221,7 +360,7 @@ async def forward_ehbp_request( response = await client.send( client.build_request( request.method, - target.url, + target_url, headers=upstream_headers, content=request_body, params=upstream.prepare_params(path, request.query_params), # type: ignore[attr-defined] @@ -255,9 +394,32 @@ async def forward_ehbp_request( status_code=response.status_code, ) - await finalize_ehbp_max_cost_payment( - key, session, max_cost_for_model, model_obj.id - ) + # Check for usage metrics in the response header (non-streaming case). + # For streaming requests, usage is delivered as an HTTP trailer after + # the body completes and is not available here — fall back to max-cost. + usage_header = response.headers.get(_RESPONSE_USAGE_HEADER) + usage_dict = parse_tinfoil_usage_metrics(usage_header) + + if usage_dict is not None: + logger.info( + "EHBP usage metrics received, finalizing with actual token cost", + extra={ + "model": model_obj.id, + "provider": provider_type, + "usage": usage_dict, + "key_hash": key.hashed_key[:8] + "...", + }, + ) + await adjust_payment_for_tokens( + key, + {"model": model_obj.id, "usage": usage_dict}, + session, + max_cost_for_model, + ) + else: + await finalize_ehbp_max_cost_payment( + key, session, max_cost_for_model, model_obj.id + ) background_tasks = BackgroundTasks() background_tasks.add_task(response.aclose) @@ -306,9 +468,11 @@ async def forward_ehbp_x_cashu_request( ) -> Response | StreamingResponse: """Redeem X-Cashu, forward EHBP opaquely, and refund unspent value. - Since the response is encrypted, usage cannot be inspected. Successful EHBP - X-Cashu requests are charged at max_cost_for_model and any excess token - value is refunded. + When the upstream returns ``X-Tinfoil-Usage-Metrics`` in the response + header (non-streaming), the refund is computed from the actual token cost + instead of max_cost_for_model. For streaming requests, usage is only + available as an HTTP trailer after the body completes and the refund + falls back to max_cost_for_model. """ request_id = getattr(request.state, "request_id", None) amount = 0 @@ -334,9 +498,8 @@ async def forward_ehbp_x_cashu_request( headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined] target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] - upstream_headers = {**headers, **dict(target.headers)} - upstream_headers.pop("x-routstr-model", None) - upstream_headers.pop("X-Routstr-Model", None) + target_url = _resolve_ehbp_target_url(target.url, path, headers) + upstream_headers = _strip_proxy_headers({**headers, **dict(target.headers)}) request_body = await request.body() client = httpx.AsyncClient( @@ -348,7 +511,7 @@ async def forward_ehbp_x_cashu_request( response = await client.send( client.build_request( request.method, - target.url, + target_url, headers=upstream_headers, content=request_body, params=upstream.prepare_params(path, request.query_params), # type: ignore[attr-defined] @@ -377,8 +540,14 @@ async def forward_ehbp_x_cashu_request( error_response.headers["X-Cashu"] = refund_token return error_response - refund_amount = amount - _msats_to_unit_amount(max_cost_for_model, unit) - response_headers = dict(response.headers) + # Compute refund from actual usage when available (non-streaming), + # otherwise fall back to max_cost_for_model. + usage_header = response.headers.get(_RESPONSE_USAGE_HEADER) + actual_cost_msats = await _compute_ehbp_actual_cost( + usage_header, model_obj, max_cost_for_model + ) + refund_amount = amount - _msats_to_unit_amount(actual_cost_msats, unit) + response_headers = _strip_proxy_headers(dict(response.headers)) if refund_amount > 0: response_headers["X-Cashu"] = await send_cashu_refund( refund_amount, unit, mint, request_id diff --git a/routstr/upstream/helpers.py b/routstr/upstream/helpers.py index 9ad4d73f..046a58e6 100644 --- a/routstr/upstream/helpers.py +++ b/routstr/upstream/helpers.py @@ -264,6 +264,7 @@ async def _seed_providers_from_settings( ("PERPLEXITY_API_KEY", "perplexity", None, None), ("FIREWORKS_API_KEY", "fireworks", None, None), ("XAI_API_KEY", "xai", None, None), + ("TINFOIL_API_KEY", "tinfoil", None, None), ] for env_key, provider_type, _, _ in env_mappings: diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py new file mode 100644 index 00000000..9f824ff2 --- /dev/null +++ b/routstr/upstream/tinfoil.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import httpx +from fastapi import Request +from fastapi.responses import Response, StreamingResponse +from pydantic.v1 import BaseModel, Field + +from ..core.exceptions import UpstreamError +from ..core.logging import get_logger +from ..payment.models import Architecture, Model, Pricing +from .base import BaseUpstreamProvider +from .ehbp import EHBPForwardingTarget + +if TYPE_CHECKING: + from ..core.db import UpstreamProviderRow + +logger = get_logger(__name__) + + +class TinfoilModelPricing(BaseModel): + inputTokenPricePer1M: float = Field(0.0) + outputTokenPricePer1M: float = Field(0.0) + requestPrice: float = 0.0 + + +class TinfoilModel(BaseModel): + id: str + context_window: int = 0 + created: int = 0 + multimodal: bool = False + reasoning: bool = False + tool_calling: bool = False + type: str = "chat" + pricing: TinfoilModelPricing = TinfoilModelPricing() + endpoints: list[str] = [] + + +class TinfoilUpstreamProvider(BaseUpstreamProvider): + """Direct upstream provider for the Tinfoil inference API. + + Tinfoil hosts open-source models inside attested secure enclaves and exposes + an OpenAI-compatible API at ``https://inference.tinfoil.sh``. Request and + response bodies are encrypted end-to-end with EHBP (HPKE), so Routstr acts + as a blind relay: it forwards the opaque encrypted body, never sees + plaintext, and bills from the ``X-Tinfoil-Usage-Metrics`` header that + Tinfoil returns outside the encrypted body when + ``X-Tinfoil-Request-Usage-Metrics: true`` is set. + """ + + provider_type = "tinfoil" + default_base_url = "https://inference.tinfoil.sh" + platform_url = "https://docs.tinfoil.sh" + supports_ehbp = True + + def __init__(self, api_key: str, provider_fee: float = 1.0): + super().__init__( + base_url=self.default_base_url, + api_key=api_key, + provider_fee=provider_fee, + ) + + @classmethod + def from_db_row( + cls, provider_row: "UpstreamProviderRow" + ) -> "TinfoilUpstreamProvider": + return cls( + api_key=provider_row.api_key, + provider_fee=provider_row.provider_fee, + ) + + @classmethod + def get_provider_metadata(cls) -> dict[str, object]: + return { + "id": cls.provider_type, + "name": "Tinfoil", + "default_base_url": cls.default_base_url, + "fixed_base_url": True, + "platform_url": cls.platform_url, + "can_create_account": False, + "can_topup": False, + "can_show_balance": False, + } + + def transform_model_name(self, model_id: str) -> str: + return model_id.removeprefix("tinfoil/") + + async def forward_get_request( + self, + request: Request, + path: str, + headers: dict, + ) -> Response | StreamingResponse: + """Handle Tinfoil-specific GET endpoints. + + * ``/attestation`` (or ``/tee/attestation``): proxy to the Tinfoil ATC + (attestation bundle proxy) at ``https://atc.tinfoil.sh/attestation``. + * Other GETs: forward to the enclave URL from ``X-Tinfoil-Enclave-Url`` + when present, otherwise to the provider base URL. + """ + clean_path = path.removeprefix("tee/") + if clean_path == "attestation": + return await self._proxy_attestation(headers) + return await super().forward_get_request(request, path, headers) + + async def _proxy_attestation(self, headers: dict) -> Response: + url = "https://atc.tinfoil.sh/attestation" + async with httpx.AsyncClient( + transport=httpx.AsyncHTTPTransport(retries=1), + timeout=30.0, + ) as client: + try: + resp = await client.get( + url, + headers={ + "Accept": headers.get("accept", "application/json"), + }, + ) + response_headers = dict(resp.headers) + response_headers.pop("content-encoding", None) + response_headers.pop("content-length", None) + return Response( + content=resp.content, + status_code=resp.status_code, + headers=response_headers, + ) + except Exception as exc: + raise UpstreamError( + f"Error fetching Tinfoil attestation: {type(exc).__name__}", + status_code=502, + ) from exc + + def get_ehbp_forwarding_target( + self, path: str, model_obj: Model + ) -> EHBPForwardingTarget: + """Return the Tinfoil enclave target for EHBP requests. + + Requests usage metrics from the enclave so Routstr can bill exactly + without decrypting the response body. The actual forwarding URL is + overridden at dispatch time by ``X-Tinfoil-Enclave-Url`` when the SDK + sends it (see ``routstr/upstream/ehbp.py``). + """ + return EHBPForwardingTarget( + url=f"{self.base_url.rstrip('/')}/{path.lstrip('/')}", + headers={"X-Tinfoil-Request-Usage-Metrics": "true"}, + ) + + async def fetch_models(self) -> list[Model]: + """Fetch models from the public Tinfoil models endpoint. + + ``GET /v1/models`` is unauthenticated and returns all available models + with their pricing in USD per 1M tokens. + """ + url = f"{self.base_url}/v1/models" + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + response.raise_for_status() + data = response.json() + models_data = data.get("data", []) + + models: list[Model] = [] + for model_data in models_data: + try: + tf = TinfoilModel.parse_obj(model_data) + input_price = tf.pricing.inputTokenPricePer1M + output_price = tf.pricing.outputTokenPricePer1M + request_price = tf.pricing.requestPrice + + modality = "text->text" + input_modalities = ["text"] + output_modalities = ["text"] + if tf.multimodal: + modality = "text->text+image" + input_modalities = ["text", "image"] + + models.append( + Model( + id=tf.id, + name=tf.id, + created=tf.created, + description=f"Tinfoil {tf.type} model", + context_length=tf.context_window, + architecture=Architecture( + modality=modality, + input_modalities=input_modalities, + output_modalities=output_modalities, + tokenizer="Unknown", + instruct_type=None, + ), + pricing=Pricing( + prompt=input_price / 1_000_000, + completion=output_price / 1_000_000, + request=request_price, + image=0.0, + web_search=0.0, + internal_reasoning=0.0, + ), + ) + ) + except Exception as e: + logger.warning( + "Failed to parse Tinfoil model", + extra={ + "model_id": model_data.get("id", "unknown"), + "error": str(e), + "error_type": type(e).__name__, + }, + ) + + return models + except Exception as e: + logger.error( + "Error fetching models from Tinfoil", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + return [] diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py new file mode 100644 index 00000000..56f5f333 --- /dev/null +++ b/tests/unit/test_tinfoil_integration.py @@ -0,0 +1,302 @@ +"""Unit tests for Tinfoil direct integration. + +Covers the EHBP usage-metrics header parser, the proxy header stripping, the +enclave URL override, and the TinfoilUpstreamProvider model fetching/forwarding +target logic. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from routstr.upstream.ehbp import ( + _PROXY_ONLY_HEADERS, + _compute_ehbp_actual_cost, + _resolve_ehbp_target_url, + _strip_proxy_headers, + parse_tinfoil_usage_metrics, +) +from routstr.upstream.tinfoil import ( + TinfoilModel, + TinfoilUpstreamProvider, +) + +# --------------------------------------------------------------------------- +# parse_tinfoil_usage_metrics +# --------------------------------------------------------------------------- + + +class TestParseTinfoilUsageMetrics: + def test_full_header(self): + result = parse_tinfoil_usage_metrics("prompt=67,completion=42,total=109") + assert result == { + "prompt_tokens": 67, + "completion_tokens": 42, + "total_tokens": 109, + } + + def test_without_total(self): + result = parse_tinfoil_usage_metrics("prompt=10,completion=5") + assert result == {"prompt_tokens": 10, "completion_tokens": 5} + + def test_none(self): + assert parse_tinfoil_usage_metrics(None) is None + + def test_empty(self): + assert parse_tinfoil_usage_metrics("") is None + + def test_malformed(self): + assert parse_tinfoil_usage_metrics("garbage") is None + + def test_missing_completion(self): + assert parse_tinfoil_usage_metrics("prompt=10") is None + + def test_extra_whitespace(self): + result = parse_tinfoil_usage_metrics( + "prompt = 100 , completion = 200 , total = 300" + ) + assert result == { + "prompt_tokens": 100, + "completion_tokens": 200, + "total_tokens": 300, + } + + +# --------------------------------------------------------------------------- +# _strip_proxy_headers +# --------------------------------------------------------------------------- + + +class TestStripProxyHeaders: + def test_strips_all_proxy_only(self): + headers = { + "x-routstr-model": "tinfoil-llama3-3-70b", + "X-Tinfoil-Enclave-Url": "https://inference.tinfoil.sh", + "X-Tinfoil-Request-Usage-Metrics": "true", + "Authorization": "Bearer secret", + "Ehbp-Encapsulated-Key": "abc123", + } + clean = _strip_proxy_headers(headers) + assert "x-routstr-model" not in clean + assert "X-Tinfoil-Enclave-Url" not in clean + assert "X-Tinfoil-Request-Usage-Metrics" not in clean + assert clean["Authorization"] == "Bearer secret" + assert clean["Ehbp-Encapsulated-Key"] == "abc123" + + def test_all_proxy_only_headers_covered(self): + assert _PROXY_ONLY_HEADERS == { + "x-routstr-model", + "x-tinfoil-enclave-url", + "x-tinfoil-request-usage-metrics", + } + + +# --------------------------------------------------------------------------- +# _resolve_ehbp_target_url +# --------------------------------------------------------------------------- + + +class TestResolveEhbpTargetUrl: + def test_override_with_enclave_url(self): + result = _resolve_ehbp_target_url( + "https://default.example.com/v1/chat/completions", + "v1/chat/completions", + {"X-Tinfoil-Enclave-Url": "https://enclave.tinfoil.sh"}, + ) + assert result == "https://enclave.tinfoil.sh/v1/chat/completions" + + def test_override_lowercase_header(self): + result = _resolve_ehbp_target_url( + "https://default.example.com/v1/chat/completions", + "v1/chat/completions", + {"x-tinfoil-enclave-url": "https://enclave.tinfoil.sh"}, + ) + assert result == "https://enclave.tinfoil.sh/v1/chat/completions" + + def test_no_override(self): + default = "https://inference.tinfoil.sh/v1/chat/completions" + result = _resolve_ehbp_target_url( + default, + "v1/chat/completions", + {}, + ) + assert result == default + + +# --------------------------------------------------------------------------- +# _compute_ehbp_actual_cost +# --------------------------------------------------------------------------- + + +class TestComputeEhbpActualCost: + @pytest.mark.asyncio + async def test_no_usage_falls_back_to_max_cost(self): + model_obj = MagicMock() + model_obj.id = "llama3-3-70b" + result = await _compute_ehbp_actual_cost(None, model_obj, 100_000) + assert result == 100_000 + + @pytest.mark.asyncio + async def test_usage_parsed_and_clamped(self): + model_obj = MagicMock() + model_obj.id = "llama3-3-70b" + # The actual cost from calculate_cost will be small; we just verify + # it's clamped to min_request_msat at minimum. + with patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=10, + output_msats=20, + total_msats=30, + total_usd=0.0001, + input_tokens=67, + output_tokens=42, + ) + result = await _compute_ehbp_actual_cost( + "prompt=67,completion=42,total=109", + model_obj, + 100_000, + ) + assert result == 30 + assert result <= 100_000 + + @pytest.mark.asyncio + async def test_max_cost_data_falls_back(self): + model_obj = MagicMock() + model_obj.id = "llama3-3-70b" + with patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import MaxCostData + + mock_calc.return_value = MaxCostData( + base_msats=0, + input_msats=0, + output_msats=0, + total_msats=0, + total_usd=0.0, + input_tokens=0, + output_tokens=0, + ) + result = await _compute_ehbp_actual_cost( + "prompt=0,completion=0", + model_obj, + 50_000, + ) + assert result == 50_000 + + +# --------------------------------------------------------------------------- +# TinfoilUpstreamProvider +# --------------------------------------------------------------------------- + + +class TestTinfoilUpstreamProvider: + def test_provider_type_and_defaults(self): + assert TinfoilUpstreamProvider.provider_type == "tinfoil" + assert ( + TinfoilUpstreamProvider.default_base_url + == "https://inference.tinfoil.sh" + ) + assert TinfoilUpstreamProvider.supports_ehbp is True + + def test_transform_model_name(self): + provider = TinfoilUpstreamProvider(api_key="test") + assert provider.transform_model_name("tinfoil/llama3-3-70b") == "llama3-3-70b" + assert provider.transform_model_name("llama3-3-70b") == "llama3-3-70b" + + def test_get_ehbp_forwarding_target_includes_usage_header(self): + provider = TinfoilUpstreamProvider(api_key="test") + model_obj = MagicMock() + model_obj.id = "llama3-3-70b" + model_obj.forwarded_model_id = "llama3-3-70b" + target = provider.get_ehbp_forwarding_target("v1/chat/completions", model_obj) + assert ( + target.headers["X-Tinfoil-Request-Usage-Metrics"] == "true" + ) + assert "v1/chat/completions" in target.url + + def test_get_provider_metadata(self): + meta = TinfoilUpstreamProvider.get_provider_metadata() + assert meta["id"] == "tinfoil" + assert meta["name"] == "Tinfoil" + assert meta["fixed_base_url"] is True + + def test_tinfoil_model_pricing_parses(self): + data = { + "id": "llama3-3-70b", + "context_window": 128000, + "created": 1721764788, + "pricing": { + "inputTokenPricePer1M": 1.75, + "outputTokenPricePer1M": 2.75, + "requestPrice": 0, + }, + "endpoints": ["/v1/chat/completions"], + "type": "chat", + } + tf = TinfoilModel.parse_obj(data) + assert tf.id == "llama3-3-70b" + assert tf.pricing.inputTokenPricePer1M == 1.75 + assert tf.pricing.outputTokenPricePer1M == 2.75 + + @pytest.mark.asyncio + async def test_fetch_models_parses_response(self): + provider = TinfoilUpstreamProvider(api_key="test") + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "data": [ + { + "id": "llama3-3-70b", + "context_window": 128000, + "created": 1721764788, + "multimodal": False, + "pricing": { + "inputTokenPricePer1M": 1.75, + "outputTokenPricePer1M": 2.75, + "requestPrice": 0, + }, + "endpoints": ["/v1/chat/completions"], + "type": "chat", + } + ] + } + + with patch("routstr.upstream.tinfoil.httpx.AsyncClient") as mock_client_cls: + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.get = AsyncMock(return_value=mock_response) + mock_client_cls.return_value = mock_client + + models = await provider.fetch_models() + + assert len(models) == 1 + assert models[0].id == "llama3-3-70b" + assert models[0].pricing.prompt == 1.75 / 1_000_000 + assert models[0].pricing.completion == 2.75 / 1_000_000 + assert models[0].context_length == 128000 + + @pytest.mark.asyncio + async def test_fetch_models_handles_error(self): + provider = TinfoilUpstreamProvider(api_key="test") + with patch("routstr.upstream.tinfoil.httpx.AsyncClient") as mock_client_cls: + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.get = AsyncMock(side_effect=Exception("network error")) + mock_client_cls.return_value = mock_client + + models = await provider.fetch_models() + + assert models == [] From 36ed3ec9f0f5e0225c7d8669af554718532e1edf Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:04:06 +0800 Subject: [PATCH 04/27] fix: validate Tinfoil EHBP enclave URL overrides --- routstr/upstream/ehbp.py | 103 +++++++++++++++++++++---- tests/unit/test_tinfoil_integration.py | 39 +++++++++- 2 files changed, 124 insertions(+), 18 deletions(-) diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 8f78b6de..8a4a7661 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -6,6 +6,7 @@ import time import traceback from dataclasses import dataclass, field from typing import Mapping +from urllib.parse import urlsplit, urlunsplit import httpx from fastapi import BackgroundTasks, Request @@ -44,6 +45,9 @@ logger = get_logger(__name__) _ENCLAVE_URL_HEADER = "X-Tinfoil-Enclave-Url" _REQUEST_USAGE_HEADER = "X-Tinfoil-Request-Usage-Metrics" _RESPONSE_USAGE_HEADER = "X-Tinfoil-Usage-Metrics" +_TINFOIL_PROVIDER_TYPE = "tinfoil" +_TINFOIL_ALLOWED_ENCLAVE_HOST_SUFFIX = ".tinfoil.sh" +_TINFOIL_ALLOWED_ENCLAVE_HOSTS = {"tinfoil.sh"} # Headers that must not be forwarded to the upstream enclave. _PROXY_ONLY_HEADERS = { @@ -84,24 +88,89 @@ def parse_tinfoil_usage_metrics(header_value: str | None) -> dict | None: return None +def _get_header_case_insensitive( + headers: Mapping[str, str], header_name: str +) -> str | None: + header_name_lower = header_name.lower() + for key, value in headers.items(): + if key.lower() == header_name_lower: + return value + return None + + +def _validated_tinfoil_enclave_base_url(enclave_url: str) -> str | None: + """Validate and normalize a Tinfoil enclave base URL. + + ``X-Tinfoil-Enclave-Url`` is client supplied. Treating it as an arbitrary + forwarding destination would let callers turn Routstr into an SSRF proxy and + exfiltrate upstream Authorization headers. Only HTTPS URLs on Tinfoil-owned + hostnames are accepted. + """ + try: + parsed = urlsplit(enclave_url.strip()) + port = parsed.port + except (TypeError, ValueError): + return None + + hostname = parsed.hostname + if not hostname: + return None + + host = hostname.rstrip(".").lower() + if parsed.scheme.lower() != "https": + return None + if parsed.username or parsed.password: + return None + if port not in (None, 443): + return None + if host not in _TINFOIL_ALLOWED_ENCLAVE_HOSTS and not host.endswith( + _TINFOIL_ALLOWED_ENCLAVE_HOST_SUFFIX + ): + return None + + # Preserve an optional base path but discard query/fragment. The request + # query string is forwarded separately via ``prepare_params``. + netloc = host if port is None else f"{host}:{port}" + return urlunsplit(("https", netloc, parsed.path.rstrip("/"), "", "")) + + def _resolve_ehbp_target_url( - target_url: str, path: str, headers: Mapping[str, str] + target_url: str, + path: str, + headers: Mapping[str, str], + provider_type: str | None = None, ) -> str: - """Override the forwarding URL with ``X-Tinfoil-Enclave-Url`` if present. + """Override Tinfoil forwarding URL with a validated enclave URL. When the Tinfoil SDK is configured with a proxy ``baseURL``, it sends the - actual enclave URL in ``X-Tinfoil-Enclave-Url``. The proxy must forward to - that URL, not to its own default, so the encrypted payload reaches the same - enclave the client verified. + actual enclave URL in ``X-Tinfoil-Enclave-Url``. The override is honored + only for the Tinfoil provider and only when the URL is an HTTPS Tinfoil + hostname, so the header cannot redirect other EHBP providers or leak + upstream API keys to arbitrary origins. """ - enclave_url = ( - headers.get(_ENCLAVE_URL_HEADER) - or headers.get(_ENCLAVE_URL_HEADER.lower()) - or headers.get(_ENCLAVE_URL_HEADER.upper()) - ) - if enclave_url: - return f"{enclave_url.rstrip('/')}/{path.lstrip('/')}" - return target_url + enclave_url = _get_header_case_insensitive(headers, _ENCLAVE_URL_HEADER) + if not enclave_url: + return target_url + + if provider_type != _TINFOIL_PROVIDER_TYPE: + logger.warning( + "Ignoring X-Tinfoil-Enclave-Url for non-Tinfoil EHBP provider", + extra={"provider": provider_type or "unknown"}, + ) + return target_url + + validated_base_url = _validated_tinfoil_enclave_base_url(enclave_url) + if validated_base_url is None: + logger.warning( + "Rejected invalid X-Tinfoil-Enclave-Url", + extra={"provider": provider_type or "unknown"}, + ) + raise UpstreamError( + "Invalid X-Tinfoil-Enclave-Url: expected an HTTPS tinfoil.sh URL", + status_code=400, + ) + + return f"{validated_base_url}/{path.lstrip('/')}" def _strip_proxy_headers(headers: dict[str, str]) -> dict[str, str]: @@ -335,10 +404,11 @@ async def forward_ehbp_request( or unsupported upstream), billing falls back to max-cost. """ target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] - target_url = _resolve_ehbp_target_url(target.url, path, headers) - upstream_headers = _strip_proxy_headers({**headers, **dict(target.headers)}) provider_type = getattr(upstream, "provider_type", "unknown") + target_url = _resolve_ehbp_target_url(target.url, path, headers, provider_type) + upstream_headers = _strip_proxy_headers({**headers, **dict(target.headers)}) + logger.debug( "Forwarding EHBP request to upstream", extra={ @@ -498,7 +568,8 @@ async def forward_ehbp_x_cashu_request( headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined] target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] - target_url = _resolve_ehbp_target_url(target.url, path, headers) + provider_type = getattr(upstream, "provider_type", "unknown") + target_url = _resolve_ehbp_target_url(target.url, path, headers, provider_type) upstream_headers = _strip_proxy_headers({**headers, **dict(target.headers)}) request_body = await request.body() diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 56f5f333..fdec9f08 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -99,19 +99,21 @@ class TestStripProxyHeaders: class TestResolveEhbpTargetUrl: - def test_override_with_enclave_url(self): + def test_override_with_enclave_url_for_tinfoil(self): 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_lowercase_header(self): + def test_override_lowercase_header_for_tinfoil(self): 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" @@ -121,9 +123,42 @@ class TestResolveEhbpTargetUrl: default, "v1/chat/completions", {}, + "tinfoil", ) assert result == default + def test_non_tinfoil_provider_ignores_enclave_url(self): + 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", + ) + assert result == default + + @pytest.mark.parametrize( + "bad_url", + [ + "http://enclave.tinfoil.sh", + "https://attacker.example", + "https://tinfoil.sh.attacker.example", + "https://127.0.0.1", + "https://enclave.tinfoil.sh:8443", + "https://user:pass@enclave.tinfoil.sh", + ], + ) + def test_tinfoil_rejects_unsafe_enclave_url(self, bad_url): + from routstr.core.exceptions import UpstreamError + + 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", + ) + # --------------------------------------------------------------------------- # _compute_ehbp_actual_cost From 55fc25b4deb9d77fc5da8a0e93837829674b841b Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:08:03 +0800 Subject: [PATCH 05/27] fix: preserve provider EHBP target headers --- routstr/upstream/ehbp.py | 18 ++++++++++++++++-- tests/unit/test_tinfoil_integration.py | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 8a4a7661..fd69fa63 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -182,6 +182,20 @@ def _strip_proxy_headers(headers: dict[str, str]) -> dict[str, str]: return clean +def _prepare_ehbp_upstream_headers( + headers: dict[str, str], target_headers: Mapping[str, str] +) -> dict[str, str]: + """Merge safe request headers with provider-controlled EHBP target headers. + + Client-supplied proxy control headers must be stripped, but provider-added + target headers such as ``X-Tinfoil-Request-Usage-Metrics: true`` must still + reach the upstream enclave. Strip first, then merge target headers so + callers cannot spoof proxy controls while providers can opt into protocol + features. + """ + return {**_strip_proxy_headers(headers), **dict(target_headers)} + + async def _compute_ehbp_actual_cost( usage_header: str | None, model_obj: Model, @@ -407,7 +421,7 @@ async def forward_ehbp_request( provider_type = getattr(upstream, "provider_type", "unknown") target_url = _resolve_ehbp_target_url(target.url, path, headers, provider_type) - upstream_headers = _strip_proxy_headers({**headers, **dict(target.headers)}) + upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers) logger.debug( "Forwarding EHBP request to upstream", @@ -570,7 +584,7 @@ async def forward_ehbp_x_cashu_request( target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] provider_type = getattr(upstream, "provider_type", "unknown") target_url = _resolve_ehbp_target_url(target.url, path, headers, provider_type) - upstream_headers = _strip_proxy_headers({**headers, **dict(target.headers)}) + upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers) request_body = await request.body() client = httpx.AsyncClient( diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index fdec9f08..8c1b6373 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -14,6 +14,7 @@ import pytest from routstr.upstream.ehbp import ( _PROXY_ONLY_HEADERS, _compute_ehbp_actual_cost, + _prepare_ehbp_upstream_headers, _resolve_ehbp_target_url, _strip_proxy_headers, parse_tinfoil_usage_metrics, @@ -93,6 +94,26 @@ class TestStripProxyHeaders: } +class TestPrepareEHBPUpstreamHeaders: + def test_strips_client_proxy_headers_before_merging_target_headers(self): + headers = { + "x-routstr-model": "tinfoil-llama3-3-70b", + "X-Tinfoil-Enclave-Url": "https://enclave.tinfoil.sh", + "X-Tinfoil-Request-Usage-Metrics": "false", + "Authorization": "Bearer upstream-key", + "Ehbp-Encapsulated-Key": "abc123", + } + target_headers = {"X-Tinfoil-Request-Usage-Metrics": "true"} + + clean = _prepare_ehbp_upstream_headers(headers, target_headers) + + assert "x-routstr-model" not in clean + assert "X-Tinfoil-Enclave-Url" not in clean + assert clean["Authorization"] == "Bearer upstream-key" + assert clean["Ehbp-Encapsulated-Key"] == "abc123" + assert clean["X-Tinfoil-Request-Usage-Metrics"] == "true" + + # --------------------------------------------------------------------------- # _resolve_ehbp_target_url # --------------------------------------------------------------------------- From 5dd3cbbc22e0dddf2da5f257e6e9afae06fc7fa1 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:40:32 +0800 Subject: [PATCH 06/27] fix: route Tinfoil attestation directly --- routstr/proxy.py | 48 ++++++++-- .../test_proxy_tinfoil_attestation_routing.py | 94 +++++++++++++++++++ 2 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_proxy_tinfoil_attestation_routing.py diff --git a/routstr/proxy.py b/routstr/proxy.py index f6977cf0..6a96e64c 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -104,6 +104,29 @@ def get_unique_models() -> list[Model]: return list(_unique_models.values()) +def _is_tinfoil_attestation_path(path: str) -> bool: + """Return True for Tinfoil attestation-bundle proxy paths.""" + return path in {"attestation", "tee/attestation"} + + +def _select_unauthenticated_get_upstreams( + path: str, upstreams: list[BaseUpstreamProvider] +) -> list[BaseUpstreamProvider]: + """Select upstream candidates for unauthenticated GET bypass paths. + + Tinfoil attestation endpoints are provider-specific. Trying every enabled + upstream can return an unrelated provider's 404 before Tinfoil is reached, + so route those paths only to Tinfoil providers. + """ + if _is_tinfoil_attestation_path(path): + return [ + upstream + for upstream in upstreams + if getattr(upstream, "provider_type", None) == "tinfoil" + ] + return upstreams + + async def refresh_model_maps() -> None: """Refresh global model and provider maps using the cost-based algorithm.""" from sqlalchemy.orm import selectinload @@ -211,20 +234,29 @@ async def proxy( model_id = request_body_dict.get("model", "unknown") # /tee/* and /attestation GET requests (e.g. Tinfoil attestation bundle) - # don't map to models — just forward to all enabled upstreams without - # model/cost/auth lookups. + # don't map to models — forward without model/cost/auth lookups. Tinfoil + # attestation paths are routed only to Tinfoil providers so an unrelated + # upstream's 404 cannot short-circuit before the attestation proxy is tried. if request.method == "GET" and ( path.startswith("tee/") or path.startswith("attestation") ): - all_upstreams = _upstreams + selected_upstreams = _select_unauthenticated_get_upstreams(path, _upstreams) + if not selected_upstreams: + return create_error_response( + "upstream_error", + "No upstream available for unauthenticated GET path", + 502, + request=request, + ) + last_error_response = None - for i, upstream in enumerate(all_upstreams): + for i, upstream in enumerate(selected_upstreams): try: headers = upstream.prepare_headers(dict(request.headers)) response = await upstream.forward_get_request(request, path, headers) - if response.status_code in [502, 429] and i < len(all_upstreams) - 1: + if response.status_code in [502, 429] and i < len(selected_upstreams) - 1: logger.warning( - "Upstream %s returned %s for tee GET %s, trying next", + "Upstream %s returned %s for unauthenticated GET %s, trying next", upstream.provider_type, response.status_code, path, @@ -233,12 +265,12 @@ async def proxy( return response except UpstreamError as e: logger.warning( - "Upstream %s failed for tee GET %s: %s", + "Upstream %s failed for unauthenticated GET %s: %s", upstream.provider_type, path, e, ) - if i == len(all_upstreams) - 1: + if i == len(selected_upstreams) - 1: last_error_response = create_error_response( "upstream_error", str(e), 502, request=request ) diff --git a/tests/unit/test_proxy_tinfoil_attestation_routing.py b/tests/unit/test_proxy_tinfoil_attestation_routing.py new file mode 100644 index 00000000..19c62c90 --- /dev/null +++ b/tests/unit/test_proxy_tinfoil_attestation_routing.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.responses import Response +from httpx import ASGITransport, AsyncClient + +from routstr import proxy as proxy_module + + +@pytest.fixture +def proxy_app() -> FastAPI: + app = FastAPI() + app.include_router(proxy_module.proxy_router) + return app + + +@pytest.mark.asyncio +async def test_attestation_get_routes_directly_to_tinfoil_provider( + monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI +) -> None: + non_tinfoil = MagicMock() + non_tinfoil.provider_type = "openai" + non_tinfoil.prepare_headers = MagicMock(return_value={}) + non_tinfoil.forward_get_request = AsyncMock( + return_value=Response(status_code=404, content=b"wrong upstream") + ) + + tinfoil = MagicMock() + tinfoil.provider_type = "tinfoil" + tinfoil.prepare_headers = MagicMock(return_value={"accept": "application/json"}) + tinfoil.forward_get_request = AsyncMock( + return_value=Response(status_code=200, content=b'{"attestation":true}') + ) + + monkeypatch.setattr(proxy_module, "_upstreams", [non_tinfoil, tinfoil]) + + async with AsyncClient( + transport=ASGITransport(app=proxy_app), base_url="http://test" + ) as client: + response = await client.get("/attestation") + + assert response.status_code == 200 + assert response.content == b'{"attestation":true}' + non_tinfoil.forward_get_request.assert_not_called() + tinfoil.forward_get_request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_tee_attestation_get_routes_directly_to_tinfoil_provider( + monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI +) -> None: + non_tinfoil = MagicMock() + non_tinfoil.provider_type = "openrouter" + non_tinfoil.prepare_headers = MagicMock(return_value={}) + non_tinfoil.forward_get_request = AsyncMock( + return_value=Response(status_code=404, content=b"wrong upstream") + ) + + tinfoil = MagicMock() + tinfoil.provider_type = "tinfoil" + tinfoil.prepare_headers = MagicMock(return_value={"accept": "application/json"}) + tinfoil.forward_get_request = AsyncMock( + return_value=Response(status_code=200, content=b'{"tee":true}') + ) + + monkeypatch.setattr(proxy_module, "_upstreams", [non_tinfoil, tinfoil]) + + async with AsyncClient( + transport=ASGITransport(app=proxy_app), base_url="http://test" + ) as client: + response = await client.get("/tee/attestation") + + assert response.status_code == 200 + assert response.content == b'{"tee":true}' + non_tinfoil.forward_get_request.assert_not_called() + tinfoil.forward_get_request.assert_awaited_once() + + +def test_attestation_upstream_selection_is_tinfoil_only() -> None: + non_tinfoil = MagicMock(provider_type="openai") + tinfoil = MagicMock(provider_type="tinfoil") + + assert proxy_module._select_unauthenticated_get_upstreams( + "attestation", [non_tinfoil, tinfoil] + ) == [tinfoil] + assert proxy_module._select_unauthenticated_get_upstreams( + "tee/attestation", [non_tinfoil, tinfoil] + ) == [tinfoil] + assert proxy_module._select_unauthenticated_get_upstreams( + "tee/other", [non_tinfoil, tinfoil] + ) == [non_tinfoil, tinfoil] From 94a3215894ddf626b3123d4cca24688d660f51f4 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:37:13 +0800 Subject: [PATCH 07/27] docs: fix forward_get_request docstring for Tinfoil The docstring claimed X-Tinfoil-Enclave-Url is honored for GET requests, but the implementation delegates to the base class which builds URLs from self.base_url. X-Tinfoil-Enclave-Url is an EHBP-only header for encrypted POST requests and is not used for unencrypted GETs. --- routstr/upstream/tinfoil.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py index 9f824ff2..fb000b86 100644 --- a/routstr/upstream/tinfoil.py +++ b/routstr/upstream/tinfoil.py @@ -96,8 +96,10 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): * ``/attestation`` (or ``/tee/attestation``): proxy to the Tinfoil ATC (attestation bundle proxy) at ``https://atc.tinfoil.sh/attestation``. - * Other GETs: forward to the enclave URL from ``X-Tinfoil-Enclave-Url`` - when present, otherwise to the provider base URL. + * Other GETs: forward to the provider base URL + (``https://inference.tinfoil.sh``). ``X-Tinfoil-Enclave-Url`` is an + EHBP-only header used for encrypted POST requests and is not honored + for unencrypted GET requests. """ clean_path = path.removeprefix("tee/") if clean_path == "attestation": From 52dd011cd8e9cf532a1e3e1ca5668922c69d7f9b Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:55:05 +0800 Subject: [PATCH 08/27] fix: add TYPE_CHECKING import for EHBPForwardingTarget (ruff F821) --- routstr/upstream/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 19c0d1e5..55b7664e 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import json import traceback +import typing import uuid from collections.abc import AsyncGenerator, AsyncIterator, Iterator from typing import Any, Mapping, cast @@ -48,6 +49,9 @@ from .cache_breakpoints import ( from .count_tokens import count_tokens_locally from .litellm_routing import detect_litellm_prefix +if typing.TYPE_CHECKING: + from .ehbp import EHBPForwardingTarget + logger = get_logger(__name__) From abc1ea5b35894221731b393e4f4d501151fdab8a Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:04:51 +0800 Subject: [PATCH 09/27] fix: resolve all mypy errors (Field defaults, missing return types, ASGITransport arg-type) --- routstr/upstream/tinfoil.py | 4 +- .../test_proxy_tinfoil_attestation_routing.py | 4 +- tests/unit/test_tinfoil_integration.py | 50 +++++++++---------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py index fb000b86..c048594e 100644 --- a/routstr/upstream/tinfoil.py +++ b/routstr/upstream/tinfoil.py @@ -20,8 +20,8 @@ logger = get_logger(__name__) class TinfoilModelPricing(BaseModel): - inputTokenPricePer1M: float = Field(0.0) - outputTokenPricePer1M: float = Field(0.0) + inputTokenPricePer1M: float = 0.0 + outputTokenPricePer1M: float = 0.0 requestPrice: float = 0.0 diff --git a/tests/unit/test_proxy_tinfoil_attestation_routing.py b/tests/unit/test_proxy_tinfoil_attestation_routing.py index 19c62c90..82acac9d 100644 --- a/tests/unit/test_proxy_tinfoil_attestation_routing.py +++ b/tests/unit/test_proxy_tinfoil_attestation_routing.py @@ -38,7 +38,7 @@ async def test_attestation_get_routes_directly_to_tinfoil_provider( monkeypatch.setattr(proxy_module, "_upstreams", [non_tinfoil, tinfoil]) async with AsyncClient( - transport=ASGITransport(app=proxy_app), base_url="http://test" + transport=ASGITransport(app=proxy_app), base_url="http://test" # type: ignore[arg-type] ) as client: response = await client.get("/attestation") @@ -69,7 +69,7 @@ async def test_tee_attestation_get_routes_directly_to_tinfoil_provider( monkeypatch.setattr(proxy_module, "_upstreams", [non_tinfoil, tinfoil]) async with AsyncClient( - transport=ASGITransport(app=proxy_app), base_url="http://test" + transport=ASGITransport(app=proxy_app), base_url="http://test" # type: ignore[arg-type] ) as client: response = await client.get("/tee/attestation") diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 8c1b6373..9e7dc635 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -30,7 +30,7 @@ from routstr.upstream.tinfoil import ( class TestParseTinfoilUsageMetrics: - def test_full_header(self): + def test_full_header(self) -> None: result = parse_tinfoil_usage_metrics("prompt=67,completion=42,total=109") assert result == { "prompt_tokens": 67, @@ -38,23 +38,23 @@ class TestParseTinfoilUsageMetrics: "total_tokens": 109, } - def test_without_total(self): + def test_without_total(self) -> None: result = parse_tinfoil_usage_metrics("prompt=10,completion=5") assert result == {"prompt_tokens": 10, "completion_tokens": 5} - def test_none(self): + def test_none(self) -> None: assert parse_tinfoil_usage_metrics(None) is None - def test_empty(self): + def test_empty(self) -> None: assert parse_tinfoil_usage_metrics("") is None - def test_malformed(self): + def test_malformed(self) -> None: assert parse_tinfoil_usage_metrics("garbage") is None - def test_missing_completion(self): + def test_missing_completion(self) -> None: assert parse_tinfoil_usage_metrics("prompt=10") is None - def test_extra_whitespace(self): + def test_extra_whitespace(self) -> None: result = parse_tinfoil_usage_metrics( "prompt = 100 , completion = 200 , total = 300" ) @@ -71,7 +71,7 @@ class TestParseTinfoilUsageMetrics: class TestStripProxyHeaders: - def test_strips_all_proxy_only(self): + def test_strips_all_proxy_only(self) -> None: headers = { "x-routstr-model": "tinfoil-llama3-3-70b", "X-Tinfoil-Enclave-Url": "https://inference.tinfoil.sh", @@ -86,7 +86,7 @@ class TestStripProxyHeaders: assert clean["Authorization"] == "Bearer secret" assert clean["Ehbp-Encapsulated-Key"] == "abc123" - def test_all_proxy_only_headers_covered(self): + def test_all_proxy_only_headers_covered(self) -> None: assert _PROXY_ONLY_HEADERS == { "x-routstr-model", "x-tinfoil-enclave-url", @@ -95,7 +95,7 @@ class TestStripProxyHeaders: class TestPrepareEHBPUpstreamHeaders: - def test_strips_client_proxy_headers_before_merging_target_headers(self): + def test_strips_client_proxy_headers_before_merging_target_headers(self) -> None: headers = { "x-routstr-model": "tinfoil-llama3-3-70b", "X-Tinfoil-Enclave-Url": "https://enclave.tinfoil.sh", @@ -120,7 +120,7 @@ class TestPrepareEHBPUpstreamHeaders: class TestResolveEhbpTargetUrl: - def test_override_with_enclave_url_for_tinfoil(self): + 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", @@ -129,7 +129,7 @@ class TestResolveEhbpTargetUrl: ) assert result == "https://enclave.tinfoil.sh/v1/chat/completions" - def test_override_lowercase_header_for_tinfoil(self): + def test_override_lowercase_header_for_tinfoil(self) -> None: result = _resolve_ehbp_target_url( "https://default.example.com/v1/chat/completions", "v1/chat/completions", @@ -138,7 +138,7 @@ class TestResolveEhbpTargetUrl: ) assert result == "https://enclave.tinfoil.sh/v1/chat/completions" - def test_no_override(self): + def test_no_override(self) -> None: default = "https://inference.tinfoil.sh/v1/chat/completions" result = _resolve_ehbp_target_url( default, @@ -148,7 +148,7 @@ class TestResolveEhbpTargetUrl: ) assert result == default - def test_non_tinfoil_provider_ignores_enclave_url(self): + def test_non_tinfoil_provider_ignores_enclave_url(self) -> None: default = "https://api.ppq.ai/private/v1/chat/completions" result = _resolve_ehbp_target_url( default, @@ -169,7 +169,7 @@ class TestResolveEhbpTargetUrl: "https://user:pass@enclave.tinfoil.sh", ], ) - def test_tinfoil_rejects_unsafe_enclave_url(self, bad_url): + def test_tinfoil_rejects_unsafe_enclave_url(self, bad_url: str) -> None: from routstr.core.exceptions import UpstreamError with pytest.raises(UpstreamError): @@ -188,14 +188,14 @@ class TestResolveEhbpTargetUrl: class TestComputeEhbpActualCost: @pytest.mark.asyncio - async def test_no_usage_falls_back_to_max_cost(self): + async def test_no_usage_falls_back_to_max_cost(self) -> None: model_obj = MagicMock() model_obj.id = "llama3-3-70b" result = await _compute_ehbp_actual_cost(None, model_obj, 100_000) assert result == 100_000 @pytest.mark.asyncio - async def test_usage_parsed_and_clamped(self): + async def test_usage_parsed_and_clamped(self) -> None: model_obj = MagicMock() model_obj.id = "llama3-3-70b" # The actual cost from calculate_cost will be small; we just verify @@ -224,7 +224,7 @@ class TestComputeEhbpActualCost: assert result <= 100_000 @pytest.mark.asyncio - async def test_max_cost_data_falls_back(self): + async def test_max_cost_data_falls_back(self) -> None: model_obj = MagicMock() model_obj.id = "llama3-3-70b" with patch( @@ -256,7 +256,7 @@ class TestComputeEhbpActualCost: class TestTinfoilUpstreamProvider: - def test_provider_type_and_defaults(self): + def test_provider_type_and_defaults(self) -> None: assert TinfoilUpstreamProvider.provider_type == "tinfoil" assert ( TinfoilUpstreamProvider.default_base_url @@ -264,12 +264,12 @@ class TestTinfoilUpstreamProvider: ) assert TinfoilUpstreamProvider.supports_ehbp is True - def test_transform_model_name(self): + def test_transform_model_name(self) -> None: provider = TinfoilUpstreamProvider(api_key="test") assert provider.transform_model_name("tinfoil/llama3-3-70b") == "llama3-3-70b" assert provider.transform_model_name("llama3-3-70b") == "llama3-3-70b" - def test_get_ehbp_forwarding_target_includes_usage_header(self): + def test_get_ehbp_forwarding_target_includes_usage_header(self) -> None: provider = TinfoilUpstreamProvider(api_key="test") model_obj = MagicMock() model_obj.id = "llama3-3-70b" @@ -280,13 +280,13 @@ class TestTinfoilUpstreamProvider: ) assert "v1/chat/completions" in target.url - def test_get_provider_metadata(self): + def test_get_provider_metadata(self) -> None: meta = TinfoilUpstreamProvider.get_provider_metadata() assert meta["id"] == "tinfoil" assert meta["name"] == "Tinfoil" assert meta["fixed_base_url"] is True - def test_tinfoil_model_pricing_parses(self): + def test_tinfoil_model_pricing_parses(self) -> None: data = { "id": "llama3-3-70b", "context_window": 128000, @@ -305,7 +305,7 @@ class TestTinfoilUpstreamProvider: assert tf.pricing.outputTokenPricePer1M == 2.75 @pytest.mark.asyncio - async def test_fetch_models_parses_response(self): + async def test_fetch_models_parses_response(self) -> None: provider = TinfoilUpstreamProvider(api_key="test") mock_response = MagicMock() mock_response.status_code = 200 @@ -344,7 +344,7 @@ class TestTinfoilUpstreamProvider: assert models[0].context_length == 128000 @pytest.mark.asyncio - async def test_fetch_models_handles_error(self): + async def test_fetch_models_handles_error(self) -> None: provider = TinfoilUpstreamProvider(api_key="test") with patch("routstr.upstream.tinfoil.httpx.AsyncClient") as mock_client_cls: mock_client = MagicMock() From 4c48df8aa8287ebc58986642e81b75cb2f82cffd Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:08:57 +0800 Subject: [PATCH 10/27] fix: remove unused Field import after mypy fix --- routstr/upstream/tinfoil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py index c048594e..021b1ce3 100644 --- a/routstr/upstream/tinfoil.py +++ b/routstr/upstream/tinfoil.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING import httpx from fastapi import Request from fastapi.responses import Response, StreamingResponse -from pydantic.v1 import BaseModel, Field +from pydantic.v1 import BaseModel from ..core.exceptions import UpstreamError from ..core.logging import get_logger From 66d7bd87b56a60add6543c214602d9a2770da32d Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:44:57 +0800 Subject: [PATCH 11/27] fix: capture HTTP trailers for EHBP streaming usage metrics Tinfoil returns X-Tinfoil-Usage-Metrics as an HTTP trailer on streaming responses, but httpx/httpcore silently discard trailers during chunked transfer decoding. This caused all streaming EHBP requests to fall back to max-cost billing instead of charging actual token usage. - Add routstr/upstream/tinfoil_trailer.py: h11-based HTTP client that preserves trailers via the EndOfMessage event (httpx discards them) - Rewrite forward_ehbp_request and forward_ehbp_x_cashu_request to use forward_with_trailer instead of httpx - Add _extract_usage_from_response() to check both response headers (non-streaming) and trailers (streaming) for usage metrics - Buffer EHBP response bodies (acceptable since they are opaque encrypted blobs the client decrypts regardless) - Both bearer and X-Cashu paths now bill based on actual token usage for both streaming and non-streaming requests --- .gitignore | 1 + routstr/upstream/ehbp.py | 294 +++++++++++++++++++--------- routstr/upstream/tinfoil_trailer.py | 140 +++++++++++++ 3 files changed, 345 insertions(+), 90 deletions(-) create mode 100644 routstr/upstream/tinfoil_trailer.py diff --git a/.gitignore b/.gitignore index f9db7ffb..13985d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ proof_backups *.todo ui_out +.worktrees diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index fd69fa63..4dc8ed8b 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -8,9 +8,8 @@ from dataclasses import dataclass, field from typing import Mapping from urllib.parse import urlsplit, urlunsplit -import httpx -from fastapi import BackgroundTasks, Request -from fastapi.responses import Response, StreamingResponse +from fastapi import Request +from fastapi.responses import Response from sqlalchemy import case from sqlmodel import col, update @@ -37,6 +36,7 @@ from ..payment.cost_calculation import ( from ..payment.helpers import create_error_response from ..payment.models import Model from ..wallet import recieve_token, send_token +from .tinfoil_trailer import forward_with_trailer logger = get_logger(__name__) @@ -85,6 +85,13 @@ def parse_tinfoil_usage_metrics(header_value: str | None) -> dict | None: if "total" in parts: result["total_tokens"] = parts["total"] return result + logger.warning( + "Failed to parse X-Tinfoil-Usage-Metrics header", + extra={ + "header_value": header_value, + "parsed_parts": parts, + }, + ) return None @@ -229,10 +236,31 @@ async def _compute_ehbp_actual_cost( return max_cost_for_model if isinstance(cost, MaxCostData): + logger.warning( + "EHBP calculate_cost returned MaxCostData (no model pricing), " + "falling back to max cost", + extra={ + "model": model_obj.id, + "max_cost_for_model": max_cost_for_model, + "usage": usage_dict, + "cost_total_msats": cost.total_msats, + }, + ) return max_cost_for_model if isinstance(cost, CostData): actual = max(int(cost.total_msats), int(settings.min_request_msat)) - return min(actual, max_cost_for_model) + clamped = min(actual, max_cost_for_model) + logger.info( + "EHBP actual cost computed from usage metrics", + extra={ + "model": model_obj.id, + "usage": usage_dict, + "cost_total_msats": cost.total_msats, + "clamped_msats": clamped, + "max_cost_for_model": max_cost_for_model, + }, + ) + return clamped # CostDataError logger.warning( "EHBP usage cost calculation error, falling back to max cost", @@ -244,6 +272,28 @@ async def _compute_ehbp_actual_cost( return max_cost_for_model +def _extract_usage_from_response( + resp_headers: list[tuple[str, str]], + trailers: list[tuple[str, str]], +) -> str | None: + """Find X-Tinfoil-Usage-Metrics in response headers or trailers. + + Non-streaming responses put usage in the response header. Streaming + responses put it in an HTTP trailer (declared via the ``Trailer:`` header). + httpx/httpcore silently discard trailers, so we use h11 directly when + forwarding EHBP requests. + """ + # Check response headers first (non-streaming case) + for k, v in resp_headers: + if k.lower() == _RESPONSE_USAGE_HEADER.lower(): + return v + # Check trailers (streaming case) + for k, v in trailers: + if k.lower() == _RESPONSE_USAGE_HEADER.lower(): + return v + return None + + @dataclass(frozen=True) class EHBPForwardingTarget: """Provider-specific destination for an EHBP opaque request.""" @@ -269,7 +319,10 @@ async def finalize_ehbp_max_cost_payment( now = int(time.time()) cleared_reserved_at = case( - (col(ApiKey.reserved_balance) - max_cost_for_model > 0, col(ApiKey.reserved_at)), + ( + col(ApiKey.reserved_balance) - max_cost_for_model > 0, + col(ApiKey.reserved_at), + ), else_=None, ) safe_reserved = case( @@ -407,15 +460,13 @@ async def forward_ehbp_request( max_cost_for_model: int, session: AsyncSession, model_obj: Model, -) -> Response | StreamingResponse: +) -> Response: """Forward an EHBP bearer-auth request and finalize billing. Sends ``X-Tinfoil-Request-Usage-Metrics: true`` so the enclave returns token counts in the ``X-Tinfoil-Usage-Metrics`` response header (non-streaming) or - trailer (streaming). When usage is available in the response header, - billing is finalized to the actual token cost via - :func:`adjust_payment_for_tokens`. When usage is not available (streaming - or unsupported upstream), billing falls back to max-cost. + trailer (streaming). Usage is captured from both response headers and HTTP + trailers via an h11-based client (httpx silently discards trailers). """ target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] @@ -423,6 +474,14 @@ async def forward_ehbp_request( target_url = _resolve_ehbp_target_url(target.url, path, headers, provider_type) upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers) + # Merge query params into the target URL since forward_with_trailer + # doesn't have a separate params argument. + query_params = upstream.prepare_params(path, request.query_params) # type: ignore[attr-defined] + if query_params: + from urllib.parse import urlencode + + target_url = f"{target_url}?{urlencode(query_params)}" + logger.debug( "Forwarding EHBP request to upstream", extra={ @@ -435,54 +494,61 @@ async def forward_ehbp_request( }, ) - client = httpx.AsyncClient( - transport=httpx.AsyncHTTPTransport(retries=1), - timeout=None, - ) - try: - response = await client.send( - client.build_request( - request.method, - target_url, - headers=upstream_headers, - content=request_body, - params=upstream.prepare_params(path, request.query_params), # type: ignore[attr-defined] - ), - stream=True, + resp = await forward_with_trailer( + method=request.method, + url=target_url, + headers=upstream_headers, + body=request_body or b"", ) - if response.status_code != 200: - body_bytes = await response.aread() - body_preview = body_bytes.decode("utf-8", errors="ignore").strip()[:500] + if resp.status_code != 200: + body_preview = resp.body.decode("utf-8", errors="ignore").strip()[:500] logger.error( "EHBP upstream %s returned %s for model=%s path=%s: %s", provider_type, - response.status_code, + resp.status_code, model_obj.id, path, body_preview or "", extra={ "provider": provider_type, "model": model_obj.id, - "status_code": response.status_code, + "status_code": resp.status_code, "path": path, "body_preview": body_preview, }, ) - await response.aclose() - await client.aclose() raise UpstreamError( - f"EHBP upstream {provider_type} returned {response.status_code} " + f"EHBP upstream {provider_type} returned {resp.status_code} " f"for model {model_obj.id}: {body_preview[:200] or ''}", - status_code=response.status_code, + status_code=resp.status_code, ) - # Check for usage metrics in the response header (non-streaming case). - # For streaming requests, usage is delivered as an HTTP trailer after - # the body completes and is not available here — fall back to max-cost. - usage_header = response.headers.get(_RESPONSE_USAGE_HEADER) + # Check for usage metrics in response headers (non-streaming) or + # trailers (streaming). h11 captures both. + usage_header = _extract_usage_from_response(resp.headers, resp.trailers) usage_dict = parse_tinfoil_usage_metrics(usage_header) + usage_source = ( + "header" + if any(k.lower() == _RESPONSE_USAGE_HEADER.lower() for k, _ in resp.headers) + else ("trailer" if resp.trailers else "none") + ) + + logger.info( + "EHBP upstream response received", + extra={ + "model": model_obj.id, + "provider": provider_type, + "target_url": target_url, + "status_code": resp.status_code, + "usage_header_raw": usage_header, + "usage_source": usage_source, + "has_trailers": bool(resp.trailers), + "body_length": len(resp.body), + "key_hash": key.hashed_key[:8] + "...", + }, + ) if usage_dict is not None: logger.info( @@ -491,6 +557,7 @@ async def forward_ehbp_request( "model": model_obj.id, "provider": provider_type, "usage": usage_dict, + "usage_source": usage_source, "key_hash": key.hashed_key[:8] + "...", }, ) @@ -501,29 +568,39 @@ async def forward_ehbp_request( max_cost_for_model, ) else: + logger.warning( + "EHBP usage metrics not found in headers or trailers, " + "falling back to max-cost billing", + extra={ + "model": model_obj.id, + "provider": provider_type, + "key_hash": key.hashed_key[:8] + "...", + }, + ) await finalize_ehbp_max_cost_payment( key, session, max_cost_for_model, model_obj.id ) - background_tasks = BackgroundTasks() - background_tasks.add_task(response.aclose) - background_tasks.add_task(client.aclose) + # Build response headers, filtering out hop-by-hop headers + response_headers: dict[str, str] = {} + hop_by_hop = { + "connection", + "keep-alive", + "transfer-encoding", + "trailer", + "content-length", + } + for k, v in resp.headers: + if k.lower() not in hop_by_hop: + response_headers[k] = v - return StreamingResponse( - response.aiter_bytes(), - status_code=response.status_code, - headers=dict(response.headers), - background=background_tasks, + return Response( + content=resp.body, + status_code=resp.status_code, + headers=response_headers, ) except UpstreamError: - await client.aclose() raise - except httpx.RequestError as exc: - await client.aclose() - raise UpstreamError( - f"Error connecting to EHBP upstream: {type(exc).__name__}", - status_code=502, - ) from exc except Exception as exc: tb = traceback.format_exc() logger.error( @@ -532,12 +609,11 @@ async def forward_ehbp_request( "error": str(exc), "error_type": type(exc).__name__, "method": request.method, - "url": target.url, + "url": target_url, "path": path, "traceback": tb, }, ) - await client.aclose() raise UpstreamError("An unexpected server error occurred", status_code=500) @@ -549,14 +625,13 @@ async def forward_ehbp_x_cashu_request( max_cost_for_model: int, model_obj: Model, upstream: object, -) -> Response | StreamingResponse: +) -> Response: """Redeem X-Cashu, forward EHBP opaquely, and refund unspent value. When the upstream returns ``X-Tinfoil-Usage-Metrics`` in the response - header (non-streaming), the refund is computed from the actual token cost - instead of max_cost_for_model. For streaming requests, usage is only - available as an HTTP trailer after the body completes and the refund - falls back to max_cost_for_model. + header (non-streaming) or as an HTTP trailer (streaming), the refund is + computed from the actual token cost. Trailers are captured via an h11-based + client because httpx silently discards them. """ request_id = getattr(request.state, "request_id", None) amount = 0 @@ -587,26 +662,22 @@ async def forward_ehbp_x_cashu_request( upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers) request_body = await request.body() - client = httpx.AsyncClient( - transport=httpx.AsyncHTTPTransport(retries=1), - timeout=None, - ) + # Merge query params into the target URL + query_params = upstream.prepare_params(path, request.query_params) # type: ignore[attr-defined] + if query_params: + from urllib.parse import urlencode + + target_url = f"{target_url}?{urlencode(query_params)}" try: - response = await client.send( - client.build_request( - request.method, - target_url, - headers=upstream_headers, - content=request_body, - params=upstream.prepare_params(path, request.query_params), # type: ignore[attr-defined] - ), - stream=True, + resp = await forward_with_trailer( + method=request.method, + url=target_url, + headers=upstream_headers, + body=request_body, ) - if response.status_code != 200: - await response.aclose() - await client.aclose() + if resp.status_code != 200: refund_token = await send_cashu_refund(amount, unit, mint, request_id) error_response = Response( content=json.dumps( @@ -614,42 +685,85 @@ async def forward_ehbp_x_cashu_request( "error": { "message": "Error forwarding EHBP request to upstream", "type": "upstream_error", - "code": response.status_code, + "code": resp.status_code, "refund_token": refund_token, } } ), - status_code=response.status_code, + status_code=resp.status_code, media_type="application/json", ) error_response.headers["X-Cashu"] = refund_token return error_response - # Compute refund from actual usage when available (non-streaming), - # otherwise fall back to max_cost_for_model. - usage_header = response.headers.get(_RESPONSE_USAGE_HEADER) + # Compute refund from actual usage when available — check both + # response headers (non-streaming) and trailers (streaming). + usage_header = _extract_usage_from_response(resp.headers, resp.trailers) + usage_source = ( + "header" + if any( + k.lower() == _RESPONSE_USAGE_HEADER.lower() for k, _ in resp.headers + ) + else ("trailer" if resp.trailers else "none") + ) + + logger.info( + "EHBP X-Cashu upstream response received", + extra={ + "model": model_obj.id, + "provider": provider_type, + "target_url": target_url, + "status_code": resp.status_code, + "usage_header_raw": usage_header, + "usage_source": usage_source, + "has_trailers": bool(resp.trailers), + "body_length": len(resp.body), + "redeemed_amount": amount, + "unit": unit, + "max_cost_for_model": max_cost_for_model, + }, + ) + actual_cost_msats = await _compute_ehbp_actual_cost( usage_header, model_obj, max_cost_for_model ) refund_amount = amount - _msats_to_unit_amount(actual_cost_msats, unit) - response_headers = _strip_proxy_headers(dict(response.headers)) + logger.info( + "EHBP X-Cashu refund computed", + extra={ + "model": model_obj.id, + "redeemed_amount": amount, + "actual_cost_msats": actual_cost_msats, + "refund_amount": refund_amount, + "unit": unit, + "usage_source": usage_source, + }, + ) + + # Build response headers, filtering out hop-by-hop headers + response_headers: dict[str, str] = {} + hop_by_hop = { + "connection", + "keep-alive", + "transfer-encoding", + "trailer", + "content-length", + } + for k, v in resp.headers: + if k.lower() not in hop_by_hop: + response_headers[k] = v + if refund_amount > 0: response_headers["X-Cashu"] = await send_cashu_refund( refund_amount, unit, mint, request_id ) - background_tasks = BackgroundTasks() - background_tasks.add_task(response.aclose) - background_tasks.add_task(client.aclose) - - return StreamingResponse( - response.aiter_bytes(), - status_code=response.status_code, + return Response( + content=resp.body, + status_code=resp.status_code, headers=response_headers, - background=background_tasks, ) except Exception: - await client.aclose() raise except Exception as e: diff --git a/routstr/upstream/tinfoil_trailer.py b/routstr/upstream/tinfoil_trailer.py new file mode 100644 index 00000000..871b778d --- /dev/null +++ b/routstr/upstream/tinfoil_trailer.py @@ -0,0 +1,140 @@ +"""h11-based HTTP client for EHBP requests that captures HTTP trailers. + +httpx/httpcore silently discard HTTP trailers during chunked transfer +decoding. Tinfoil returns ``X-Tinfoil-Usage-Metrics`` as a trailer on +streaming responses, so we need a lower-level HTTP client that preserves +trailers from the h11 ``EndOfMessage`` event. + +Because EHBP response bodies are opaque encrypted blobs, buffering the full +response is acceptable — the client decrypts the complete body regardless of +whether it arrived streamed or buffered. +""" + +from __future__ import annotations + +import asyncio +import ssl +from dataclasses import dataclass, field +from urllib.parse import urlsplit + +import h11 + +from ..core import get_logger + +logger = get_logger(__name__) + +_READ_BUFSIZE = 65536 + + +@dataclass +class TrailerResponse: + """Buffered HTTP response with optional trailer headers.""" + + status_code: int + headers: list[tuple[str, str]] + body: bytes + trailers: list[tuple[str, str]] = field(default_factory=list) + + +def _get_header(headers: list[tuple[str, str]], name: str) -> str | None: + name_lower = name.lower() + for k, v in headers: + if k.lower() == name_lower: + return v + return None + + +async def forward_with_trailer( + *, + method: str, + url: str, + headers: dict[str, str], + body: bytes, +) -> TrailerResponse: + """Send an HTTP/1.1 request via h11 and capture HTTP trailers. + + Returns a :class:`TrailerResponse` with the full buffered body and any + trailer headers from the ``EndOfMessage`` event. + """ + parsed = urlsplit(url) + host = parsed.hostname + if not host: + raise ValueError(f"Invalid URL (no hostname): {url}") + port = parsed.port or 443 + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + + ssl_ctx = ssl.create_default_context() + reader, writer = await asyncio.open_connection(host, port, ssl=ssl_ctx) + + try: + # Build HTTP/1.1 request + header_lines = [f"{method} {path} HTTP/1.1"] + has_host = any(k.lower() == "host" for k in headers) + if not has_host: + header_lines.append(f"Host: {host}") + header_lines.append("Connection: close") + + for key, value in headers.items(): + if key.lower() in ("host", "connection"): + continue + header_lines.append(f"{key}: {value}") + + if body and not any(k.lower() == "content-length" for k in headers): + header_lines.append(f"Content-Length: {len(body)}") + + request_data = "\r\n".join(header_lines).encode() + b"\r\n\r\n" + if body: + request_data += body + + writer.write(request_data) + await writer.drain() + + # Parse response with h11 + conn = h11.Connection(h11.CLIENT) + status_code = 0 + resp_headers: list[tuple[str, str]] = [] + body_chunks: list[bytes] = [] + trailers: list[tuple[str, str]] = [] + + while True: + event = conn.next_event() + + if event is h11.NEED_DATA: + data = await reader.read(_READ_BUFSIZE) + conn.receive_data(data if data else b"") + continue + + if isinstance(event, h11.Response): + status_code = event.status_code + resp_headers = [(k.decode(), v.decode()) for k, v in event.headers] + + elif isinstance(event, h11.Data): + body_chunks.append(event.data) + + elif isinstance(event, h11.EndOfMessage): + for k, v in event.headers: + trailers.append((k.decode(), v.decode())) + break + + elif isinstance(event, h11.PAUSED): + # Shouldn't happen for simple request/response, but break safely + logger.warning("h11 PAUSED event during EHBP response parsing") + break + + elif isinstance(event, h11.ConnectionClosed): + break + + return TrailerResponse( + status_code=status_code, + headers=resp_headers, + body=b"".join(body_chunks), + trailers=trailers, + ) + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass From e4753b586439f1282bda956fc3e8715dcd0eaf47 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:00:41 +0800 Subject: [PATCH 12/27] fix: stream buffered EHBP body instead of returning fixed-length Response The Tinfoil SDK expects chunked transfer encoding for streaming responses. Returning Response with content=body caused the stream to terminate abruptly instead of ending gracefully. Use StreamingResponse with a generator that yields the buffered body so the client gets proper chunked transfer encoding while still benefiting from trailer capture and exact billing. --- routstr/upstream/ehbp.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 4dc8ed8b..df0b5b7e 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -5,11 +5,11 @@ import math import time import traceback from dataclasses import dataclass, field -from typing import Mapping +from typing import AsyncIterator, Mapping from urllib.parse import urlsplit, urlunsplit from fastapi import Request -from fastapi.responses import Response +from fastapi.responses import Response, StreamingResponse from sqlalchemy import case from sqlmodel import col, update @@ -460,7 +460,7 @@ async def forward_ehbp_request( max_cost_for_model: int, session: AsyncSession, model_obj: Model, -) -> Response: +) -> Response | StreamingResponse: """Forward an EHBP bearer-auth request and finalize billing. Sends ``X-Tinfoil-Request-Usage-Metrics: true`` so the enclave returns token @@ -594,8 +594,11 @@ async def forward_ehbp_request( if k.lower() not in hop_by_hop: response_headers[k] = v - return Response( - content=resp.body, + async def _stream_body() -> AsyncIterator[bytes]: + yield resp.body + + return StreamingResponse( + _stream_body(), status_code=resp.status_code, headers=response_headers, ) @@ -625,7 +628,7 @@ async def forward_ehbp_x_cashu_request( max_cost_for_model: int, model_obj: Model, upstream: object, -) -> Response: +) -> Response | StreamingResponse: """Redeem X-Cashu, forward EHBP opaquely, and refund unspent value. When the upstream returns ``X-Tinfoil-Usage-Metrics`` in the response @@ -758,8 +761,11 @@ async def forward_ehbp_x_cashu_request( refund_amount, unit, mint, request_id ) - return Response( - content=resp.body, + async def _stream_body_xcashu() -> AsyncIterator[bytes]: + yield resp.body + + return StreamingResponse( + _stream_body_xcashu(), status_code=resp.status_code, headers=response_headers, ) From a8f60643e04146b070db5452384e027a6ecb74eb Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:42:01 +0800 Subject: [PATCH 13/27] feat: return per-request cost headers on EHBP/Tinfoil responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EHBP response bodies are opaque encrypted blobs, so cost cannot be injected into JSON like the normal proxy flow. Instead, surface cost as response headers: X-Routstr-Cost-Msats — total msats charged (bearer + x-cashu) X-Routstr-Cost-Usd — USD equivalent (bearer only) X-Routstr-Input-Cost-Msats — msats attributed to input tokens X-Routstr-Output-Cost-Msats— msats attributed to output tokens - Refactor _compute_ehbp_actual_cost from int→dict to carry full cost breakdown - Add _build_cost_info() and _inject_cost_response_headers() helpers - Capture adjust_payment_for_tokens return in bearer path (was discarded) - Update CORS expose_headers, docs, and unit tests --- docs/tinfoil-direct-integration.md | 22 ++++++- routstr/core/main.py | 9 ++- routstr/upstream/ehbp.py | 86 +++++++++++++++++++++++--- tests/unit/test_tinfoil_integration.py | 17 +++-- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/docs/tinfoil-direct-integration.md b/docs/tinfoil-direct-integration.md index 9660d224..22d1ab37 100644 --- a/docs/tinfoil-direct-integration.md +++ b/docs/tinfoil-direct-integration.md @@ -418,9 +418,27 @@ and `routstr/upstream/ehbp.py`. | Request shape | Usage source | Billing | |---|---|---| | Bearer, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Exact token cost via `adjust_payment_for_tokens` | -| Bearer, streaming | HTTP trailer (not available before body) | Max-cost fallback | +| Bearer, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Exact token cost (h11 captures trailers) | +| Bearer, no usage header/trailer | N/A | Max-cost fallback | | X-Cashu, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Refund = `redeemed - actual_cost` | -| X-Cashu, streaming | HTTP trailer | Refund = `redeemed - max_cost` | +| X-Cashu, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Refund = `redeemed - actual_cost` (h11 captures trailers) | +| X-Cashu, no usage header/trailer | N/A | Refund = `redeemed - max_cost` | + +### Cost response headers + +Since EHBP response bodies are opaque encrypted blobs, per-request cost cannot +be injected into the JSON body (as done in the normal proxy flow). Instead, +Routstr returns cost info as response headers: + +| Header | Auth | Description | +|---|---|---| +| `X-Routstr-Cost-Msats` | Bearer, X-Cashu | Total msats charged for this request | +| `X-Routstr-Cost-Usd` | Bearer | USD equivalent of the charge | +| `X-Routstr-Input-Cost-Msats` | Bearer, X-Cashu | msats attributed to input tokens | +| `X-Routstr-Output-Cost-Msats` | Bearer, X-Cashu | msats attributed to output tokens | + +The client/Tinfoil SDK can read these headers from the HTTP response without +needing to decrypt the body. ### Setup diff --git a/routstr/core/main.py b/routstr/core/main.py index 2fca2338..216a3412 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -241,7 +241,14 @@ app.add_middleware( allow_credentials=True, allow_methods=["*"], allow_headers=["*"], - expose_headers=["x-routstr-request-id", "x-cashu"], + expose_headers=[ + "x-routstr-request-id", + "x-cashu", + "x-routstr-cost-msats", + "x-routstr-cost-usd", + "x-routstr-input-cost-msats", + "x-routstr-output-cost-msats", + ], ) # Add logging middleware diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index df0b5b7e..9de94d79 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -203,21 +203,56 @@ def _prepare_ehbp_upstream_headers( return {**_strip_proxy_headers(headers), **dict(target_headers)} +def _build_cost_info( + total_msats: int, + input_tokens: int = 0, + output_tokens: int = 0, + input_msats: int = 0, + output_msats: int = 0, +) -> dict: + """Build a cost-info dict with token counts and per-token-type costs.""" + return { + "total_msats": total_msats, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + "input_msats": input_msats, + "output_msats": output_msats, + } + + +def _inject_cost_response_headers( + headers: dict[str, str], cost_info: dict +) -> None: + """Add per-request cost headers to an EHBP response. + + Since EHBP response bodies are opaque encrypted blobs, cost cannot be + injected into the JSON body. Instead, it goes into response headers that + the client/Tinfoil SDK can read without decrypting. + """ + headers["X-Routstr-Cost-Msats"] = str(cost_info["total_msats"]) + headers["X-Routstr-Input-Cost-Msats"] = str(cost_info["input_msats"]) + headers["X-Routstr-Output-Cost-Msats"] = str(cost_info["output_msats"]) + + async def _compute_ehbp_actual_cost( usage_header: str | None, model_obj: Model, max_cost_for_model: int, -) -> int: +) -> dict: """Compute the actual cost in msats from Tinfoil usage metrics. Falls back to ``max_cost_for_model`` when usage is absent (streaming) or cannot be priced. The result is clamped to ``[min_request_msat, max_cost_for_model]`` so the refund never exceeds the reservation and is never zero. + + Returns a dict with ``total_msats``, ``input_tokens``, ``output_tokens``, + ``total_tokens``, ``input_msats``, and ``output_msats``. """ usage_dict = parse_tinfoil_usage_metrics(usage_header) if usage_dict is None: - return max_cost_for_model + return _build_cost_info(max_cost_for_model) try: cost = await calculate_cost( @@ -233,7 +268,7 @@ async def _compute_ehbp_actual_cost( "usage": usage_dict, }, ) - return max_cost_for_model + return _build_cost_info(max_cost_for_model) if isinstance(cost, MaxCostData): logger.warning( @@ -246,7 +281,7 @@ async def _compute_ehbp_actual_cost( "cost_total_msats": cost.total_msats, }, ) - return max_cost_for_model + return _build_cost_info(max_cost_for_model) if isinstance(cost, CostData): actual = max(int(cost.total_msats), int(settings.min_request_msat)) clamped = min(actual, max_cost_for_model) @@ -260,7 +295,13 @@ async def _compute_ehbp_actual_cost( "max_cost_for_model": max_cost_for_model, }, ) - return clamped + return _build_cost_info( + total_msats=clamped, + input_tokens=cost.input_tokens, + output_tokens=cost.output_tokens, + input_msats=cost.input_msats, + output_msats=cost.output_msats, + ) # CostDataError logger.warning( "EHBP usage cost calculation error, falling back to max cost", @@ -269,7 +310,7 @@ async def _compute_ehbp_actual_cost( "error": getattr(cost, "message", str(cost)), }, ) - return max_cost_for_model + return _build_cost_info(max_cost_for_model) def _extract_usage_from_response( @@ -561,7 +602,7 @@ async def forward_ehbp_request( "key_hash": key.hashed_key[:8] + "...", }, ) - await adjust_payment_for_tokens( + cost_data = await adjust_payment_for_tokens( key, {"model": model_obj.id, "usage": usage_dict}, session, @@ -580,6 +621,25 @@ async def forward_ehbp_request( await finalize_ehbp_max_cost_payment( key, session, max_cost_for_model, model_obj.id ) + cost_data = { + "total_msats": max_cost_for_model, + "total_usd": 0.0, + "input_tokens": 0, + "output_tokens": 0, + } + + # Build the cost_info dict from what adjust_payment_for_tokens returned + # or from the max-cost fallback. Fields match CostData/MaxCostData.dict(). + cost_info = { + "total_msats": cost_data.get("total_msats", max_cost_for_model), + "input_tokens": cost_data.get("input_tokens", 0), + "output_tokens": cost_data.get("output_tokens", 0), + "total_tokens": cost_data.get("input_tokens", 0) + + cost_data.get("output_tokens", 0), + "input_msats": cost_data.get("input_msats", 0), + "output_msats": cost_data.get("output_msats", 0), + } + cost_usd = cost_data.get("total_usd", 0.0) # Build response headers, filtering out hop-by-hop headers response_headers: dict[str, str] = {} @@ -594,6 +654,11 @@ async def forward_ehbp_request( if k.lower() not in hop_by_hop: response_headers[k] = v + # Surface per-request cost to the client. Since EHBP bodies are + # opaque, cost info can only go into response headers. + _inject_cost_response_headers(response_headers, cost_info) + response_headers["X-Routstr-Cost-Usd"] = str(cost_usd) + async def _stream_body() -> AsyncIterator[bytes]: yield resp.body @@ -727,9 +792,10 @@ async def forward_ehbp_x_cashu_request( }, ) - actual_cost_msats = await _compute_ehbp_actual_cost( + cost_info = await _compute_ehbp_actual_cost( usage_header, model_obj, max_cost_for_model ) + actual_cost_msats = cost_info["total_msats"] refund_amount = amount - _msats_to_unit_amount(actual_cost_msats, unit) logger.info( "EHBP X-Cashu refund computed", @@ -756,6 +822,10 @@ async def forward_ehbp_x_cashu_request( if k.lower() not in hop_by_hop: response_headers[k] = v + # Surface per-request cost to the client. Since EHBP bodies are + # opaque encrypted blobs, cost can only go into response headers. + _inject_cost_response_headers(response_headers, cost_info) + if refund_amount > 0: response_headers["X-Cashu"] = await send_cashu_refund( refund_amount, unit, mint, request_id diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 9e7dc635..7183b49a 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -192,7 +192,9 @@ class TestComputeEhbpActualCost: model_obj = MagicMock() model_obj.id = "llama3-3-70b" result = await _compute_ehbp_actual_cost(None, model_obj, 100_000) - assert result == 100_000 + assert result["total_msats"] == 100_000 + assert result["input_tokens"] == 0 + assert result["output_tokens"] == 0 @pytest.mark.asyncio async def test_usage_parsed_and_clamped(self) -> None: @@ -220,8 +222,13 @@ class TestComputeEhbpActualCost: model_obj, 100_000, ) - assert result == 30 - assert result <= 100_000 + assert result["total_msats"] == 30 + assert result["total_msats"] <= 100_000 + assert result["input_tokens"] == 67 + assert result["output_tokens"] == 42 + assert result["total_tokens"] == 109 + assert result["input_msats"] == 10 + assert result["output_msats"] == 20 @pytest.mark.asyncio async def test_max_cost_data_falls_back(self) -> None: @@ -247,7 +254,9 @@ class TestComputeEhbpActualCost: model_obj, 50_000, ) - assert result == 50_000 + assert result["total_msats"] == 50_000 + assert result["input_tokens"] == 0 + assert result["output_tokens"] == 0 # --------------------------------------------------------------------------- From 46b1f8f72dcefb7797ae1df3d78691d28560da1f Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:51:31 +0800 Subject: [PATCH 14/27] chore: remove secp256k1 git source pin --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bc07d62c..7886e54e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,6 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } -secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" } [tool.uv] exclude-newer = "2 weeks" From bd4f4e820759dfdd1610e7fb3658bd08408b556a Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:33:25 +0800 Subject: [PATCH 15/27] fix: expose Ehbp-Response-Nonce and Ehbp-Encapsulated-Key in CORS Browser clients need these EHBP protocol headers visible to JavaScript so the Tinfoil SDK can detect and decrypt encrypted responses. Without them, CORS hides the headers, the SDK treats the response as a plaintext proxy error, and users see 'The provider did not respond to this request.' Node.js scripts are unaffected (no CORS enforcement). See ../routstr-chat/TINFOIL_CORS_ISSUE.md for full root-cause analysis. --- routstr/core/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/routstr/core/main.py b/routstr/core/main.py index 3001e56b..c9598a1c 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -264,6 +264,12 @@ app.add_middleware( "x-routstr-cost-usd", "x-routstr-input-cost-msats", "x-routstr-output-cost-msats", + # EHBP (Tinfoil) protocol headers must be exposed so browser clients + # can detect and decrypt encrypted responses. Without these, the + # browser hides them via CORS and the SDK treats the response as a + # plaintext proxy error, returning raw ciphertext. + "Ehbp-Response-Nonce", + "Ehbp-Encapsulated-Key", ], ) From 07d39c2a7b765694f7d3f04090b4279f7a072a1a Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 30 Jun 2026 23:50:25 +0200 Subject: [PATCH 16/27] simplify --- pyproject.toml | 4 +- routstr/proxy.py | 12 +- routstr/upstream/base.py | 6 +- routstr/upstream/ehbp.py | 280 ++++++++++++++---- routstr/upstream/ppqai.py | 5 +- routstr/upstream/tinfoil.py | 21 +- routstr/upstream/tinfoil_trailer.py | 37 ++- .../test_proxy_tinfoil_attestation_routing.py | 24 ++ tests/unit/test_tinfoil_trailer.py | 90 ++++++ uv.lock | 2 + 10 files changed, 408 insertions(+), 73 deletions(-) create mode 100644 tests/unit/test_tinfoil_trailer.py diff --git a/pyproject.toml b/pyproject.toml index 7886e54e..eed8c69d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "aiosqlite>=0.20", "sqlmodel>=0.0.24", "httpx[socks]>=0.25.2", + "h11>=0.14", "greenlet>=3.2.1", "alembic>=1.13", "python-json-logger>=2.0.0", @@ -86,6 +87,3 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } - -[tool.uv] -exclude-newer = "2 weeks" diff --git a/routstr/proxy.py b/routstr/proxy.py index 7a0a1517..cd2369b2 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -234,12 +234,14 @@ async def proxy( else: model_id = request_body_dict.get("model", "unknown") - # /tee/* and /attestation GET requests (e.g. Tinfoil attestation bundle) - # don't map to models — forward without model/cost/auth lookups. Tinfoil - # attestation paths are routed only to Tinfoil providers so an unrelated - # upstream's 404 cannot short-circuit before the attestation proxy is tried. + # /tee/*, /attestation and /.well-known/* GET requests don't map to models + # — forward without model/cost/auth lookups. Tinfoil attestation paths are + # routed only to Tinfoil providers so an unrelated upstream's 404 cannot + # short-circuit before the attestation proxy is tried. if request.method == "GET" and ( - path.startswith("tee/") or path.startswith("attestation") + path.startswith("tee/") + or path.startswith("attestation") + or path.startswith(".well-known/") ): selected_upstreams = _select_unauthenticated_get_upstreams(path, _upstreams) if not selected_upstreams: diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index adcd7411..0fe26962 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -53,7 +53,7 @@ from .litellm_routing import detect_litellm_prefix from .rate_limit import UPSTREAM_RATE_LIMIT, classify_rate_limit if typing.TYPE_CHECKING: - from .ehbp import EHBPForwardingTarget + from .ehbp import ConfidentialInferenceProfile, EHBPForwardingTarget logger = get_logger(__name__) @@ -2825,6 +2825,10 @@ class BaseUpstreamProvider: supports_ehbp: bool = False + def get_confidential_inference_profile(self) -> "ConfidentialInferenceProfile | None": + """Return provider policy for encrypted/confidential inference forwarding.""" + return None + def get_ehbp_forwarding_target( self, path: str, model_obj: Model ) -> "EHBPForwardingTarget": diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 9de94d79..3bebe9ce 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -15,7 +15,6 @@ from sqlmodel import col, update from ..auth import ( ROUTSTR_FEE_PERCENT, - adjust_payment_for_tokens, get_billing_key, payments_logger, ) @@ -40,21 +39,25 @@ from .tinfoil_trailer import forward_with_trailer logger = get_logger(__name__) -# Headers that the Tinfoil SDK sends to tell the proxy where to forward the -# encrypted request, and the request/response usage-metrics pair. +# Provider-neutral confidential-inference defaults. Tinfoil is the first +# EHBP implementation, but provider-specific routing, usage extraction and +# header policy belong in a profile so future TEE providers do not inherit +# Tinfoil-only assumptions. _ENCLAVE_URL_HEADER = "X-Tinfoil-Enclave-Url" _REQUEST_USAGE_HEADER = "X-Tinfoil-Request-Usage-Metrics" _RESPONSE_USAGE_HEADER = "X-Tinfoil-Usage-Metrics" _TINFOIL_PROVIDER_TYPE = "tinfoil" _TINFOIL_ALLOWED_ENCLAVE_HOST_SUFFIX = ".tinfoil.sh" -_TINFOIL_ALLOWED_ENCLAVE_HOSTS = {"tinfoil.sh"} +_TINFOIL_ALLOWED_ENCLAVE_HOSTS = frozenset({"tinfoil.sh"}) # Headers that must not be forwarded to the upstream enclave. -_PROXY_ONLY_HEADERS = { - "x-routstr-model", - "x-tinfoil-enclave-url", - "x-tinfoil-request-usage-metrics", -} +_PROXY_ONLY_HEADERS = frozenset( + { + "x-routstr-model", + "x-tinfoil-enclave-url", + "x-tinfoil-request-usage-metrics", + } +) def parse_tinfoil_usage_metrics(header_value: str | None) -> dict | None: @@ -146,51 +149,78 @@ def _resolve_ehbp_target_url( path: str, headers: Mapping[str, str], provider_type: str | None = None, + profile: "ConfidentialInferenceProfile | None" = None, ) -> str: - """Override Tinfoil forwarding URL with a validated enclave URL. + """Resolve the provider-approved destination for an EHBP request. - When the Tinfoil SDK is configured with a proxy ``baseURL``, it sends the - actual enclave URL in ``X-Tinfoil-Enclave-Url``. The override is honored - only for the Tinfoil provider and only when the URL is an HTTPS Tinfoil - hostname, so the header cannot redirect other EHBP providers or leak - upstream API keys to arbitrary origins. + Tinfoil can send the actual enclave URL in ``X-Tinfoil-Enclave-Url`` when + the SDK is pointed at a Routstr proxy. A provider profile must explicitly + 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. """ - enclave_url = _get_header_case_insensitive(headers, _ENCLAVE_URL_HEADER) + override_header = profile.client_target_url_header if profile else _ENCLAVE_URL_HEADER + if not override_header: + return target_url + enclave_url = _get_header_case_insensitive(headers, override_header) if not enclave_url: return target_url - if provider_type != _TINFOIL_PROVIDER_TYPE: + if profile is None: + if provider_type != _TINFOIL_PROVIDER_TYPE: + logger.warning( + "Ignoring EHBP target override for provider without profile", + extra={"provider": provider_type or "unknown"}, + ) + return target_url + validated_base_url = _validated_tinfoil_enclave_base_url(enclave_url) + elif not profile.allow_client_target_override: logger.warning( - "Ignoring X-Tinfoil-Enclave-Url for non-Tinfoil EHBP provider", + "Ignoring EHBP target override for provider profile", extra={"provider": provider_type or "unknown"}, ) return target_url + else: + validated_base_url = _validated_confidential_target_url(enclave_url, profile) - validated_base_url = _validated_tinfoil_enclave_base_url(enclave_url) if validated_base_url is None: logger.warning( - "Rejected invalid X-Tinfoil-Enclave-Url", + "Rejected invalid EHBP target override", extra={"provider": provider_type or "unknown"}, ) raise UpstreamError( - "Invalid X-Tinfoil-Enclave-Url: expected an HTTPS tinfoil.sh URL", + f"Invalid {override_header}: target is not allowed for this provider", status_code=400, ) return f"{validated_base_url}/{path.lstrip('/')}" -def _strip_proxy_headers(headers: dict[str, str]) -> dict[str, str]: +def _validated_confidential_target_url( + enclave_url: str, profile: "ConfidentialInferenceProfile" +) -> str | None: + if profile.client_target_url_header == _ENCLAVE_URL_HEADER: + return _validated_tinfoil_enclave_base_url(enclave_url) + return None + + +def _strip_proxy_headers( + headers: dict[str, str], + profile: "ConfidentialInferenceProfile | None" = None, +) -> dict[str, str]: """Remove proxy-routing headers that must not reach the upstream enclave.""" + proxy_only_headers = profile.proxy_only_headers if profile else _PROXY_ONLY_HEADERS clean = {} for key, value in headers.items(): - if key.lower() not in _PROXY_ONLY_HEADERS: + if key.lower() not in proxy_only_headers: clean[key] = value return clean def _prepare_ehbp_upstream_headers( - headers: dict[str, str], target_headers: Mapping[str, str] + headers: dict[str, str], + target_headers: Mapping[str, str], + profile: "ConfidentialInferenceProfile | None" = None, ) -> dict[str, str]: """Merge safe request headers with provider-controlled EHBP target headers. @@ -200,7 +230,7 @@ def _prepare_ehbp_upstream_headers( callers cannot spoof proxy controls while providers can opt into protocol features. """ - return {**_strip_proxy_headers(headers), **dict(target_headers)} + return {**_strip_proxy_headers(headers, profile), **dict(target_headers)} def _build_cost_info( @@ -316,31 +346,149 @@ async def _compute_ehbp_actual_cost( def _extract_usage_from_response( resp_headers: list[tuple[str, str]], trailers: list[tuple[str, str]], + usage_header_name: str | None = _RESPONSE_USAGE_HEADER, ) -> str | None: - """Find X-Tinfoil-Usage-Metrics in response headers or trailers. + """Find provider usage metrics in response headers or trailers. - Non-streaming responses put usage in the response header. Streaming - responses put it in an HTTP trailer (declared via the ``Trailer:`` header). - httpx/httpcore silently discard trailers, so we use h11 directly when - forwarding EHBP requests. + Non-streaming responses put usage in a response header. Streaming responses + put it in an HTTP trailer. httpx/httpcore silently discard trailers, so we + use h11 directly when forwarding EHBP requests. """ - # Check response headers first (non-streaming case) + if not usage_header_name: + return None + usage_header_name_lower = usage_header_name.lower() for k, v in resp_headers: - if k.lower() == _RESPONSE_USAGE_HEADER.lower(): + if k.lower() == usage_header_name_lower: return v - # Check trailers (streaming case) for k, v in trailers: - if k.lower() == _RESPONSE_USAGE_HEADER.lower(): + if k.lower() == usage_header_name_lower: return v return None +@dataclass(frozen=True) +class ConfidentialInferenceProfile: + """Provider-neutral policy for encrypted/confidential inference forwarding.""" + + protocol: str = "EHBP" + usage_response_header: str | None = None + client_target_url_header: str | None = None + allow_client_target_override: bool = False + trusted_model_binding_header: str | None = None + missing_usage_billing_policy: str = "max_cost" + proxy_only_headers: frozenset[str] = _PROXY_ONLY_HEADERS + + @dataclass(frozen=True) class EHBPForwardingTarget: """Provider-specific destination for an EHBP opaque request.""" url: str headers: Mapping[str, str] = field(default_factory=dict) + profile: ConfidentialInferenceProfile | None = None + + +async def finalize_ehbp_actual_cost_payment( + key: ApiKey, + session: AsyncSession, + reserved_cost_for_model: int, + model_id: str, + cost_info: dict, +) -> None: + """Finalize an EHBP bearer request using clamped provider usage metrics.""" + billing_key = await get_billing_key(key, session) + total_cost_msats = max(0, int(cost_info.get("total_msats", reserved_cost_for_model))) + now = int(time.time()) + + safe_reserved = case( + ( + col(ApiKey.reserved_balance) >= reserved_cost_for_model, + col(ApiKey.reserved_balance) - reserved_cost_for_model, + ), + else_=0, + ) + cleared_reserved_at = case( + ( + col(ApiKey.reserved_balance) - reserved_cost_for_model > 0, + col(ApiKey.reserved_at), + ), + else_=None, + ) + + stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == billing_key.hashed_key) + .values( + reserved_balance=safe_reserved, + reserved_at=cleared_reserved_at, + balance=col(ApiKey.balance) - total_cost_msats, + total_spent=col(ApiKey.total_spent) + total_cost_msats, + ) + ) + result = await session.exec(stmt) # type: ignore[call-overload] + + child_result = None + if billing_key.hashed_key != key.hashed_key: + child_stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == key.hashed_key) + .values( + reserved_balance=safe_reserved, + reserved_at=cleared_reserved_at, + total_spent=col(ApiKey.total_spent) + total_cost_msats, + ) + ) + child_result = await session.exec(child_stmt) # type: ignore[call-overload] + + if result.rowcount == 0 or (child_result is not None and child_result.rowcount == 0): + await session.rollback() + logger.error( + "Failed to finalize EHBP usage-based payment", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "billing_key_hash": billing_key.hashed_key[:8] + "...", + "model": model_id, + "reserved_cost_for_model": reserved_cost_for_model, + "total_cost_msats": total_cost_msats, + "parent_rowcount": result.rowcount, + "child_rowcount": getattr(child_result, "rowcount", None), + }, + ) + return + + await session.commit() + await session.refresh(billing_key) + if billing_key.hashed_key != key.hashed_key: + await session.refresh(key) + + if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0: + fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100) + try: + await accumulate_routstr_fee(session, fee_msats) + except Exception as e: + logger.warning( + "Failed to accumulate Routstr fee for EHBP request", + extra={"error": str(e), "fee_msats": fee_msats}, + ) + + payments_logger.info( + "FINALIZE", + extra={ + "event": "finalize", + "key_hash": key.hashed_key[:8] + "...", + "billing_key_hash": billing_key.hashed_key[:8] + "...", + "model": model_id, + "cost_reserved": reserved_cost_for_model, + "cost_charged": total_cost_msats, + "input_tokens": cost_info.get("input_tokens", 0), + "output_tokens": cost_info.get("output_tokens", 0), + "balance": billing_key.balance, + "reserved_balance": billing_key.reserved_balance, + "total_spent": billing_key.total_spent, + "finalize_type": "ehbp_usage", + "finalized_at": now, + }, + ) async def finalize_ehbp_max_cost_payment( @@ -410,11 +558,12 @@ async def finalize_ehbp_max_cost_payment( total_spent=col(ApiKey.total_spent) + total_cost_msats, ) ) - await session.exec(child_stmt) # type: ignore[call-overload] + child_result = await session.exec(child_stmt) # type: ignore[call-overload] + else: + child_result = None - await session.commit() - - if result.rowcount == 0: + if result.rowcount == 0 or (child_result is not None and child_result.rowcount == 0): + await session.rollback() logger.error( "Failed to finalize EHBP max-cost payment", extra={ @@ -422,10 +571,14 @@ async def finalize_ehbp_max_cost_payment( "billing_key_hash": billing_key.hashed_key[:8] + "...", "model": model_id, "max_cost_for_model": max_cost_for_model, + "parent_rowcount": result.rowcount, + "child_rowcount": getattr(child_result, "rowcount", None), }, ) return + await session.commit() + await session.refresh(billing_key) if billing_key.hashed_key != key.hashed_key: await session.refresh(key) @@ -512,8 +665,11 @@ async def forward_ehbp_request( target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] provider_type = getattr(upstream, "provider_type", "unknown") - target_url = _resolve_ehbp_target_url(target.url, path, headers, provider_type) - upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers) + 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 + ) + upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers, profile) # Merge query params into the target URL since forward_with_trailer # doesn't have a separate params argument. @@ -568,12 +724,18 @@ async def forward_ehbp_request( # Check for usage metrics in response headers (non-streaming) or # trailers (streaming). h11 captures both. - usage_header = _extract_usage_from_response(resp.headers, resp.trailers) + usage_header_name = ( + profile.usage_response_header if profile else _RESPONSE_USAGE_HEADER + ) + usage_header = _extract_usage_from_response( + resp.headers, resp.trailers, usage_header_name + ) usage_dict = parse_tinfoil_usage_metrics(usage_header) usage_source = ( "header" - if any(k.lower() == _RESPONSE_USAGE_HEADER.lower() for k, _ in resp.headers) - else ("trailer" if resp.trailers else "none") + if usage_header_name + and any(k.lower() == usage_header_name.lower() for k, _ in resp.headers) + else ("trailer" if usage_header else "none") ) logger.info( @@ -602,12 +764,13 @@ async def forward_ehbp_request( "key_hash": key.hashed_key[:8] + "...", }, ) - cost_data = await adjust_payment_for_tokens( - key, - {"model": model_obj.id, "usage": usage_dict}, - session, - max_cost_for_model, + cost_info = await _compute_ehbp_actual_cost( + usage_header, model_obj, max_cost_for_model ) + await finalize_ehbp_actual_cost_payment( + key, session, max_cost_for_model, model_obj.id, cost_info + ) + cost_data = {**cost_info, "total_usd": 0.0} else: logger.warning( "EHBP usage metrics not found in headers or trailers, " @@ -726,8 +889,11 @@ async def forward_ehbp_x_cashu_request( headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined] target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] provider_type = getattr(upstream, "provider_type", "unknown") - target_url = _resolve_ehbp_target_url(target.url, path, headers, provider_type) - upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers) + 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 + ) + upstream_headers = _prepare_ehbp_upstream_headers(headers, target.headers, profile) request_body = await request.body() # Merge query params into the target URL @@ -766,13 +932,19 @@ async def forward_ehbp_x_cashu_request( # Compute refund from actual usage when available — check both # response headers (non-streaming) and trailers (streaming). - usage_header = _extract_usage_from_response(resp.headers, resp.trailers) + usage_header_name = ( + profile.usage_response_header if profile else _RESPONSE_USAGE_HEADER + ) + usage_header = _extract_usage_from_response( + resp.headers, resp.trailers, usage_header_name + ) usage_source = ( "header" - if any( - k.lower() == _RESPONSE_USAGE_HEADER.lower() for k, _ in resp.headers + if usage_header_name + and any( + k.lower() == usage_header_name.lower() for k, _ in resp.headers ) - else ("trailer" if resp.trailers else "none") + else ("trailer" if usage_header else "none") ) logger.info( diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py index 8cf1cc62..14d44d06 100644 --- a/routstr/upstream/ppqai.py +++ b/routstr/upstream/ppqai.py @@ -40,7 +40,10 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): default_base_url = "https://api.ppq.ai" platform_url = "https://ppq.ai/api-docs" IGNORED_MODEL_IDS: list[str] = ["auto"] - supports_ehbp = True + # PPQ.AI has a private encrypted endpoint, but this proxy currently has no + # provider-attested usage extractor/model binding for it. Keep EHBP disabled + # until a ConfidentialInferenceProfile can bill it without max-cost fallback. + supports_ehbp = False def __init__(self, api_key: str, provider_fee: float = 1.0): super().__init__( diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py index 021b1ce3..d751c57f 100644 --- a/routstr/upstream/tinfoil.py +++ b/routstr/upstream/tinfoil.py @@ -11,7 +11,13 @@ from ..core.exceptions import UpstreamError from ..core.logging import get_logger from ..payment.models import Architecture, Model, Pricing from .base import BaseUpstreamProvider -from .ehbp import EHBPForwardingTarget +from .ehbp import ( + _ENCLAVE_URL_HEADER, + _PROXY_ONLY_HEADERS, + _RESPONSE_USAGE_HEADER, + ConfidentialInferenceProfile, + EHBPForwardingTarget, +) if TYPE_CHECKING: from ..core.db import UpstreamProviderRow @@ -53,6 +59,15 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): default_base_url = "https://inference.tinfoil.sh" platform_url = "https://docs.tinfoil.sh" supports_ehbp = True + confidential_inference_profile = ConfidentialInferenceProfile( + protocol="EHBP", + usage_response_header=_RESPONSE_USAGE_HEADER, + client_target_url_header=_ENCLAVE_URL_HEADER, + allow_client_target_override=True, + trusted_model_binding_header=None, + missing_usage_billing_policy="max_cost", + proxy_only_headers=_PROXY_ONLY_HEADERS, + ) def __init__(self, api_key: str, provider_fee: float = 1.0): super().__init__( @@ -86,6 +101,9 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): def transform_model_name(self, model_id: str) -> str: return model_id.removeprefix("tinfoil/") + def get_confidential_inference_profile(self) -> ConfidentialInferenceProfile: + return self.confidential_inference_profile + async def forward_get_request( self, request: Request, @@ -146,6 +164,7 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): return EHBPForwardingTarget( url=f"{self.base_url.rstrip('/')}/{path.lstrip('/')}", headers={"X-Tinfoil-Request-Usage-Metrics": "true"}, + profile=self.confidential_inference_profile, ) async def fetch_models(self) -> list[Model]: diff --git a/routstr/upstream/tinfoil_trailer.py b/routstr/upstream/tinfoil_trailer.py index 871b778d..abffd6ba 100644 --- a/routstr/upstream/tinfoil_trailer.py +++ b/routstr/upstream/tinfoil_trailer.py @@ -24,6 +24,9 @@ from ..core import get_logger logger = get_logger(__name__) _READ_BUFSIZE = 65536 +_DEFAULT_TIMEOUT_SECONDS = 30.0 +_DEFAULT_CLOSE_TIMEOUT_SECONDS = 1.0 +_DEFAULT_MAX_RESPONSE_BYTES = 25 * 1024 * 1024 @dataclass @@ -50,6 +53,9 @@ async def forward_with_trailer( url: str, headers: dict[str, str], body: bytes, + timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS, + max_response_bytes: int = _DEFAULT_MAX_RESPONSE_BYTES, + close_timeout_seconds: float = _DEFAULT_CLOSE_TIMEOUT_SECONDS, ) -> TrailerResponse: """Send an HTTP/1.1 request via h11 and capture HTTP trailers. @@ -66,7 +72,10 @@ async def forward_with_trailer( path = f"{path}?{parsed.query}" ssl_ctx = ssl.create_default_context() - reader, writer = await asyncio.open_connection(host, port, ssl=ssl_ctx) + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port, ssl=ssl_ctx), + timeout=timeout_seconds, + ) try: # Build HTTP/1.1 request @@ -89,20 +98,24 @@ async def forward_with_trailer( request_data += body writer.write(request_data) - await writer.drain() + await asyncio.wait_for(writer.drain(), timeout=timeout_seconds) # Parse response with h11 conn = h11.Connection(h11.CLIENT) status_code = 0 resp_headers: list[tuple[str, str]] = [] body_chunks: list[bytes] = [] + body_size = 0 trailers: list[tuple[str, str]] = [] while True: event = conn.next_event() if event is h11.NEED_DATA: - data = await reader.read(_READ_BUFSIZE) + data = await asyncio.wait_for( + reader.read(_READ_BUFSIZE), + timeout=timeout_seconds, + ) conn.receive_data(data if data else b"") continue @@ -111,6 +124,11 @@ async def forward_with_trailer( resp_headers = [(k.decode(), v.decode()) for k, v in event.headers] elif isinstance(event, h11.Data): + body_size += len(event.data) + if body_size > max_response_bytes: + raise ValueError( + f"EHBP response exceeded {max_response_bytes} bytes" + ) body_chunks.append(event.data) elif isinstance(event, h11.EndOfMessage): @@ -133,8 +151,11 @@ async def forward_with_trailer( trailers=trailers, ) finally: - try: - writer.close() - await writer.wait_closed() - except Exception: - pass + writer.close() + if close_timeout_seconds > 0: + try: + await asyncio.wait_for( + writer.wait_closed(), timeout=close_timeout_seconds + ) + except Exception: + pass diff --git a/tests/unit/test_proxy_tinfoil_attestation_routing.py b/tests/unit/test_proxy_tinfoil_attestation_routing.py index 82acac9d..6d76029f 100644 --- a/tests/unit/test_proxy_tinfoil_attestation_routing.py +++ b/tests/unit/test_proxy_tinfoil_attestation_routing.py @@ -92,3 +92,27 @@ def test_attestation_upstream_selection_is_tinfoil_only() -> None: assert proxy_module._select_unauthenticated_get_upstreams( "tee/other", [non_tinfoil, tinfoil] ) == [non_tinfoil, tinfoil] + + +@pytest.mark.asyncio +async def test_well_known_get_bypasses_model_lookup( + monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI +) -> None: + upstream = MagicMock() + upstream.provider_type = "openai" + upstream.prepare_headers = MagicMock(return_value={}) + upstream.forward_get_request = AsyncMock( + return_value=Response(status_code=200, content=b"lnurl metadata") + ) + + monkeypatch.setattr(proxy_module, "_upstreams", [upstream]) + monkeypatch.setattr(proxy_module, "get_model_instance", MagicMock(side_effect=AssertionError)) + + async with AsyncClient( + transport=ASGITransport(app=proxy_app), base_url="http://test" # type: ignore[arg-type] + ) as client: + response = await client.get("/.well-known/lnurlp/alice") + + assert response.status_code == 200 + assert response.content == b"lnurl metadata" + upstream.forward_get_request.assert_awaited_once() diff --git a/tests/unit/test_tinfoil_trailer.py b/tests/unit/test_tinfoil_trailer.py new file mode 100644 index 00000000..d8319375 --- /dev/null +++ b/tests/unit/test_tinfoil_trailer.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from routstr.upstream.tinfoil_trailer import forward_with_trailer + + +class FakeReader: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + + async def read(self, _size: int) -> bytes: + if self._chunks: + return self._chunks.pop(0) + return b"" + + +class FakeWriter: + def __init__(self) -> None: + self.written = b"" + self.drain = AsyncMock() + self.wait_closed = AsyncMock() + self.close = MagicMock() + + def write(self, data: bytes) -> None: + self.written += data + + +@pytest.mark.asyncio +async def test_forward_with_trailer_captures_usage_trailer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Transfer-Encoding: chunked\r\n" + b"Trailer: X-Tinfoil-Usage-Metrics\r\n" + b"\r\n" + b"5\r\nhello\r\n" + b"0\r\n" + b"X-Tinfoil-Usage-Metrics: prompt=1,completion=2,total=3\r\n" + b"\r\n" + ) + reader = FakeReader([response]) + writer = FakeWriter() + open_connection = AsyncMock(return_value=(reader, writer)) + monkeypatch.setattr( + "routstr.upstream.tinfoil_trailer.asyncio.open_connection", open_connection + ) + + result = await forward_with_trailer( + method="POST", + url="https://enclave.tinfoil.sh/v1/chat/completions?stream=true", + headers={"Authorization": "Bearer upstream"}, + body=b"opaque", + ) + + assert result.status_code == 200 + assert result.body == b"hello" + assert result.trailers == [ + ("x-tinfoil-usage-metrics", "prompt=1,completion=2,total=3") + ] + assert b"Connection: close" in writer.written + writer.close.assert_called_once() + writer.wait_closed.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_forward_with_trailer_enforces_response_size_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello" + reader = FakeReader([response]) + writer = FakeWriter() + monkeypatch.setattr( + "routstr.upstream.tinfoil_trailer.asyncio.open_connection", + AsyncMock(return_value=(reader, writer)), + ) + + with pytest.raises(ValueError, match="EHBP response exceeded"): + await forward_with_trailer( + method="POST", + url="https://enclave.tinfoil.sh/v1/chat/completions", + headers={}, + body=b"opaque", + max_response_bytes=4, + ) + + writer.close.assert_called_once() diff --git a/uv.lock b/uv.lock index 34df7542..b549bd4d 100644 --- a/uv.lock +++ b/uv.lock @@ -2392,6 +2392,7 @@ dependencies = [ { name = "cashu" }, { name = "fastapi", extra = ["standard"] }, { name = "greenlet" }, + { name = "h11" }, { name = "httpx", extra = ["socks"] }, { name = "litellm" }, { name = "marshmallow" }, @@ -2426,6 +2427,7 @@ requires-dist = [ { name = "cashu", specifier = ">=0.20" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115" }, { name = "greenlet", specifier = ">=3.2.1" }, + { name = "h11", specifier = ">=0.14" }, { name = "httpx", extras = ["socks"], specifier = ">=0.25.2" }, { name = "litellm", specifier = ">=1.55.0" }, { name = "marshmallow", specifier = ">=3.13,<4.0" }, From 7328a5ac457bff08b7d6f0b3bb0d40d1eab98692 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 1 Jul 2026 22:29:07 +0200 Subject: [PATCH 17/27] Address EHBP review comments --- routstr/upstream/ehbp.py | 18 ++- routstr/upstream/tinfoil.py | 3 - tests/unit/test_ehbp_finalize_payment.py | 185 +++++++++++++++++++++++ 3 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_ehbp_finalize_payment.py diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 3bebe9ce..e5c073d3 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -199,6 +199,9 @@ def _resolve_ehbp_target_url( def _validated_confidential_target_url( enclave_url: str, profile: "ConfidentialInferenceProfile" ) -> str | None: + # Client target overrides are Tinfoil-only for now. Future confidential + # inference providers must add their own constrained validator here before + # opting into ``allow_client_target_override``. if profile.client_target_url_header == _ENCLAVE_URL_HEADER: return _validated_tinfoil_enclave_base_url(enclave_url) return None @@ -370,12 +373,9 @@ def _extract_usage_from_response( class ConfidentialInferenceProfile: """Provider-neutral policy for encrypted/confidential inference forwarding.""" - protocol: str = "EHBP" usage_response_header: str | None = None client_target_url_header: str | None = None allow_client_target_override: bool = False - trusted_model_binding_header: str | None = None - missing_usage_billing_policy: str = "max_cost" proxy_only_headers: frozenset[str] = _PROXY_ONLY_HEADERS @@ -397,6 +397,8 @@ async def finalize_ehbp_actual_cost_payment( ) -> None: """Finalize an EHBP bearer request using clamped provider usage metrics.""" billing_key = await get_billing_key(key, session) + key_hash = key.hashed_key + billing_key_hash = billing_key.hashed_key total_cost_msats = max(0, int(cost_info.get("total_msats", reserved_cost_for_model))) now = int(time.time()) @@ -445,8 +447,8 @@ async def finalize_ehbp_actual_cost_payment( logger.error( "Failed to finalize EHBP usage-based payment", extra={ - "key_hash": key.hashed_key[:8] + "...", - "billing_key_hash": billing_key.hashed_key[:8] + "...", + "key_hash": key_hash[:8] + "...", + "billing_key_hash": billing_key_hash[:8] + "...", "model": model_id, "reserved_cost_for_model": reserved_cost_for_model, "total_cost_msats": total_cost_msats, @@ -504,6 +506,8 @@ async def finalize_ehbp_max_cost_payment( cost and releases the reservation. """ billing_key = await get_billing_key(key, session) + key_hash = key.hashed_key + billing_key_hash = billing_key.hashed_key total_cost_msats = max(0, int(max_cost_for_model)) now = int(time.time()) @@ -567,8 +571,8 @@ async def finalize_ehbp_max_cost_payment( logger.error( "Failed to finalize EHBP max-cost payment", extra={ - "key_hash": key.hashed_key[:8] + "...", - "billing_key_hash": billing_key.hashed_key[:8] + "...", + "key_hash": key_hash[:8] + "...", + "billing_key_hash": billing_key_hash[:8] + "...", "model": model_id, "max_cost_for_model": max_cost_for_model, "parent_rowcount": result.rowcount, diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py index d751c57f..4e8b94c8 100644 --- a/routstr/upstream/tinfoil.py +++ b/routstr/upstream/tinfoil.py @@ -60,12 +60,9 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): platform_url = "https://docs.tinfoil.sh" supports_ehbp = True confidential_inference_profile = ConfidentialInferenceProfile( - protocol="EHBP", usage_response_header=_RESPONSE_USAGE_HEADER, client_target_url_header=_ENCLAVE_URL_HEADER, allow_client_target_override=True, - trusted_model_binding_header=None, - missing_usage_billing_policy="max_cost", proxy_only_headers=_PROXY_ONLY_HEADERS, ) diff --git a/tests/unit/test_ehbp_finalize_payment.py b/tests/unit/test_ehbp_finalize_payment.py new file mode 100644 index 00000000..d105edfe --- /dev/null +++ b/tests/unit/test_ehbp_finalize_payment.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from typing import AsyncGenerator + +import pytest +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlalchemy.pool import StaticPool +from sqlmodel import SQLModel, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core.db import ApiKey +from routstr.upstream.ehbp import ( + finalize_ehbp_actual_cost_payment, + finalize_ehbp_max_cost_payment, +) + + +def _make_engine() -> AsyncEngine: + return create_async_engine( + "sqlite+aiosqlite://", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + + +@pytest.fixture +async def session(monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[AsyncSession, None]: + monkeypatch.setattr("routstr.upstream.ehbp.ROUTSTR_FEE_PERCENT", 0) + engine = _make_engine() + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + db_session = AsyncSession(engine, expire_on_commit=False) + try: + yield db_session + finally: + await db_session.close() + await engine.dispose() + + +async def _api_key(session: AsyncSession, hashed_key: str) -> ApiKey | None: + return ( + await session.exec(select(ApiKey).where(ApiKey.hashed_key == hashed_key)) + ).one_or_none() + + +@pytest.mark.asyncio +async def test_finalize_actual_cost_payment_updates_balance_and_releases_reserve( + session: AsyncSession, +) -> None: + key = ApiKey( + hashed_key="ehbp-actual", + balance=10_000, + reserved_balance=3_000, + reserved_at=123, + ) + session.add(key) + await session.commit() + + await finalize_ehbp_actual_cost_payment( + key, + session, + reserved_cost_for_model=3_000, + model_id="tinfoil/model", + cost_info={ + "total_msats": 1_200, + "input_tokens": 10, + "output_tokens": 20, + "input_msats": 500, + "output_msats": 700, + }, + ) + + updated = await _api_key(session, "ehbp-actual") + assert updated is not None + assert updated.balance == 8_800 + assert updated.reserved_balance == 0 + assert updated.reserved_at is None + assert updated.total_spent == 1_200 + + +@pytest.mark.asyncio +async def test_finalize_max_cost_payment_updates_parent_and_child_spend( + session: AsyncSession, +) -> None: + parent = ApiKey( + hashed_key="ehbp-parent", + balance=10_000, + reserved_balance=3_000, + reserved_at=123, + ) + child = ApiKey( + hashed_key="ehbp-child", + balance=0, + reserved_balance=3_000, + reserved_at=123, + parent_key_hash="ehbp-parent", + ) + session.add(parent) + session.add(child) + await session.commit() + + await finalize_ehbp_max_cost_payment( + child, + session, + max_cost_for_model=3_000, + model_id="tinfoil/model", + ) + + updated_parent = await _api_key(session, "ehbp-parent") + updated_child = await _api_key(session, "ehbp-child") + assert updated_parent is not None + assert updated_child is not None + assert updated_parent.balance == 7_000 + assert updated_parent.reserved_balance == 0 + assert updated_parent.reserved_at is None + assert updated_parent.total_spent == 3_000 + assert updated_child.balance == 0 + assert updated_child.reserved_balance == 0 + assert updated_child.reserved_at is None + assert updated_child.total_spent == 3_000 + + +@pytest.mark.asyncio +async def test_finalize_actual_cost_payment_rolls_back_when_parent_update_matches_no_rows( + session: AsyncSession, +) -> None: + key = ApiKey( + hashed_key="ehbp-missing-parent", + balance=10_000, + reserved_balance=3_000, + reserved_at=123, + ) + session.add(key) + await session.commit() + await session.delete(key) + await session.commit() + + await finalize_ehbp_actual_cost_payment( + key, + session, + reserved_cost_for_model=3_000, + model_id="tinfoil/model", + cost_info={"total_msats": 1_200}, + ) + + assert await _api_key(session, "ehbp-missing-parent") is None + + +@pytest.mark.asyncio +async def test_finalize_max_cost_payment_rolls_back_parent_when_child_update_matches_no_rows( + session: AsyncSession, +) -> None: + parent = ApiKey( + hashed_key="ehbp-rollback-parent", + balance=10_000, + reserved_balance=3_000, + reserved_at=123, + ) + child = ApiKey( + hashed_key="ehbp-missing-child", + balance=0, + reserved_balance=3_000, + reserved_at=123, + parent_key_hash="ehbp-rollback-parent", + ) + session.add(parent) + session.add(child) + await session.commit() + await session.delete(child) + await session.commit() + + await finalize_ehbp_max_cost_payment( + child, + session, + max_cost_for_model=3_000, + model_id="tinfoil/model", + ) + + updated_parent = await _api_key(session, "ehbp-rollback-parent") + assert updated_parent is not None + assert updated_parent.balance == 10_000 + assert updated_parent.reserved_balance == 3_000 + assert updated_parent.reserved_at == 123 + assert updated_parent.total_spent == 0 + assert await _api_key(session, "ehbp-missing-child") is None From ab5596ed70ac5ea8a809b69ab5dbc3b8f2b3d7ba Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:09:58 +0530 Subject: [PATCH 18/27] remove .well-known/ from Tinfoil attestation routing Tinfoil's proxy server guide only requires /attestation (GET) and /v1/chat/completions + /v1/responses (POST). The .well-known/ path was never requested by Tinfoil and was incorrectly added to: - _API_PATH_PREFIXES (prefix gate) - the unauthenticated GET bypass branch - the Tinfoil integration docs Remove it from all three. --- docs/tinfoil-direct-integration.md | 4 ++-- routstr/proxy.py | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/tinfoil-direct-integration.md b/docs/tinfoil-direct-integration.md index 22d1ab37..ce689203 100644 --- a/docs/tinfoil-direct-integration.md +++ b/docs/tinfoil-direct-integration.md @@ -410,8 +410,8 @@ and `routstr/upstream/ehbp.py`. - `forward_ehbp_x_cashu_request()`: if usage is available, computes the refund from actual cost instead of max cost. -- `routstr/proxy.py`: `/attestation` and `/.well-known/` paths are forwarded - to all enabled upstreams without model/cost/auth lookups. +- `routstr/proxy.py`: `/attestation` and `/tee/attestation` paths are forwarded + to Tinfoil upstreams without model/cost/auth lookups. ### Billing behavior diff --git a/routstr/proxy.py b/routstr/proxy.py index cd2369b2..593eea81 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -191,7 +191,6 @@ _API_PATH_PREFIXES = ( "providers", "tee/", "attestation", - ".well-known/", ) @@ -234,14 +233,12 @@ async def proxy( else: model_id = request_body_dict.get("model", "unknown") - # /tee/*, /attestation and /.well-known/* GET requests don't map to models - # — forward without model/cost/auth lookups. Tinfoil attestation paths are - # routed only to Tinfoil providers so an unrelated upstream's 404 cannot + # /tee/* and /attestation GET requests don't map to models — forward + # without model/cost/auth lookups. Tinfoil attestation paths are routed + # only to Tinfoil providers so an unrelated upstream's 404 cannot # short-circuit before the attestation proxy is tried. if request.method == "GET" and ( - path.startswith("tee/") - or path.startswith("attestation") - or path.startswith(".well-known/") + path.startswith("tee/") or path.startswith("attestation") ): selected_upstreams = _select_unauthenticated_get_upstreams(path, _upstreams) if not selected_upstreams: From 65ac1b0dcd78559cb4de9c4edc3d98b03bf17742 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:00 +0530 Subject: [PATCH 19/27] remove test for .well-known/ bypass (path no longer routed) The .well-known/ path was removed from Tinfoil attestation routing since Tinfoil doesn't use it. The test that asserted GET /.well-known/ bypasses model lookup is no longer valid. --- .../test_proxy_tinfoil_attestation_routing.py | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/tests/unit/test_proxy_tinfoil_attestation_routing.py b/tests/unit/test_proxy_tinfoil_attestation_routing.py index 6d76029f..35b618ca 100644 --- a/tests/unit/test_proxy_tinfoil_attestation_routing.py +++ b/tests/unit/test_proxy_tinfoil_attestation_routing.py @@ -93,26 +93,3 @@ def test_attestation_upstream_selection_is_tinfoil_only() -> None: "tee/other", [non_tinfoil, tinfoil] ) == [non_tinfoil, tinfoil] - -@pytest.mark.asyncio -async def test_well_known_get_bypasses_model_lookup( - monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI -) -> None: - upstream = MagicMock() - upstream.provider_type = "openai" - upstream.prepare_headers = MagicMock(return_value={}) - upstream.forward_get_request = AsyncMock( - return_value=Response(status_code=200, content=b"lnurl metadata") - ) - - monkeypatch.setattr(proxy_module, "_upstreams", [upstream]) - monkeypatch.setattr(proxy_module, "get_model_instance", MagicMock(side_effect=AssertionError)) - - async with AsyncClient( - transport=ASGITransport(app=proxy_app), base_url="http://test" # type: ignore[arg-type] - ) as client: - response = await client.get("/.well-known/lnurlp/alice") - - assert response.status_code == 200 - assert response.content == b"lnurl metadata" - upstream.forward_get_request.assert_awaited_once() From af2abc3a1c6ba1bb1bd08175a8ac8fe178892056 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:15:34 +0800 Subject: [PATCH 20/27] Rename TinfoilUpstreamProvider.from_db_row to _build_from_row Aligns Tinfoil with the base class hook pattern: subclasses override _build_from_row so the base from_db_row wrapper stamps db_id onto the instance. Previously Tinfoil overrode from_db_row directly, bypassing the identity-stamping wrapper. --- routstr/upstream/tinfoil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py index 4e8b94c8..795ae904 100644 --- a/routstr/upstream/tinfoil.py +++ b/routstr/upstream/tinfoil.py @@ -74,7 +74,7 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "TinfoilUpstreamProvider": return cls( From 0f7d3d2f8618593f144212496c9b2a8b4c451156 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:44:42 +0800 Subject: [PATCH 21/27] fix(wallet): return 425 when refund is pending, not 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The X-Cashu refund endpoint raised 404 "Refund not found" when the "in" transaction existed with a request_id but the "out" (refund) transaction had not been written yet. This is a timing race: the endpoint is polled while the upstream request is still in flight, before send_refund() has minted and stored the refund token. The 404 was indistinguishable from a genuinely-missing refund, so clients had no signal that retrying would succeed — leading to stranded refunds when clients gave up. Replace the third 404 branch with 425 Too Early + Retry-After: 2. The two earlier 404 branches (no "in" row, no request_id) remain 404 since those genuinely mean no refund will ever exist. Also adds debug logging on all three not-found/pending branches so the race is no longer invisible to operators (middleware does not log request headers). Adds unit tests for the new 425 pending path and the no-request_id 404 path. Refs: refund-race-condition --- routstr/balance.py | 15 +++++++++- tests/unit/test_balance.py | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/routstr/balance.py b/routstr/balance.py index 2f24cb8c..5d69b179 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -274,7 +274,20 @@ async def refund_wallet_endpoint( ) out_tx = out_tx_result.first() if out_tx is None: - raise HTTPException(status_code=404, detail="Refund not found") + # The "in" row exists with a request_id, but the "out" (refund) + # row hasn't been written yet — the upstream request is still in + # flight and the refund will be minted once it completes. Tell the + # client to retry instead of 404ing permanently (race condition + # where /v1/wallet/refund is polled before the refund exists). + logger.debug( + "refund_wallet_endpoint: refund pending (in row exists, out row not yet created)", + extra={"request_id": in_tx.request_id}, + ) + raise HTTPException( + status_code=425, + detail="Refund is pending; retry shortly.", + headers={"Retry-After": "2"}, + ) if out_tx.swept: raise HTTPException(status_code=410, detail="Refund has been swept") diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index ba9d54da..6450e9f2 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -103,6 +103,64 @@ async def test_refund_x_cashu_not_found_raises_404() -> None: assert exc_info.value.status_code == 404 +@pytest.mark.asyncio +async def test_refund_x_cashu_pending_raises_425() -> None: + """in row exists with a request_id but out row not yet created → 425. + + This is the race condition where /v1/wallet/refund is polled while the + upstream request is still in flight. The endpoint must signal "retry" + rather than a permanent 404. + """ + from fastapi import HTTPException + + x_cashu_token = "cashuApending_token" + in_tx = _make_cashu_tx( + token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-pending" + ) + + session = MagicMock() + session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(None)]) + session.add = MagicMock() + session.commit = AsyncMock() + + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization="Bearer sk-somekey", + x_cashu=x_cashu_token, + session=session, + ) + + assert exc_info.value.status_code == 425 + assert exc_info.value.headers == {"Retry-After": "2"} + + +@pytest.mark.asyncio +async def test_refund_x_cashu_in_tx_without_request_id_raises_404() -> None: + """in row exists but has no request_id (cannot link to a refund) → 404. + + This is a genuine "no refund will ever exist" case, distinct from the + pending 425 path. + """ + from fastapi import HTTPException + + x_cashu_token = "cashuAnoreqid_token" + in_tx = _make_cashu_tx( + token=x_cashu_token, amount=0, unit="msat", type="in", request_id=None + ) + + session = MagicMock() + session.exec = AsyncMock(side_effect=[_exec_result(in_tx)]) + + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization="Bearer sk-somekey", + x_cashu=x_cashu_token, + session=session, + ) + + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio async def test_refund_x_cashu_swept_raises_410() -> None: from fastapi import HTTPException From 4287f038cf66295ff892f8dfec0c80e02bf9115a Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:39:33 +0530 Subject: [PATCH 22/27] feat(ehbp): extract & use model name from Tinfoil usage metrics header Tinfoil PR #385 added model= to the X-Tinfoil-Usage-Metrics header/trailer. This commit uses that field for accurate billing. Changes in routstr/upstream/ehbp.py: - parse_tinfoil_usage_metrics(): extract the model= field as a string alongside the existing token counts (previously silently discarded because int() failed on it). - _build_cost_info(): accept optional actual_model parameter propagated through to callers when a real mismatch is detected. - _compute_ehbp_actual_cost(): compare the served model against model_obj.forwarded_model_id (the expected upstream ID) rather than model_obj.id (the client-facing alias). This prevents spurious mismatches when a node runner maps e.g. tinfoil-glm-5-2 -> glm-5-2 and the header correctly reports glm-5-2. On a genuine mismatch (failover to a different upstream model), look up the actual model's pricing via get_model_instance() (forwarded_model_id values are registered as routable aliases in the global model map). - forward_ehbp_request() / forward_ehbp_x_cashu_request(): use the actual served model for payment finalization and logging when a mismatch is detected. Tests: 6 new scenarios (alias match, real mismatch with alias, unknown model fallback, old-format compat, cache token details + model), plus forwarded_model_id set on all existing mock model objects to keep them passing. All 49 Tinfoil/EHBP unit tests pass. --- docs/tinfoil-direct-integration.md | 45 ++++- routstr/upstream/ehbp.py | 121 +++++++++++-- tests/unit/test_tinfoil_integration.py | 225 +++++++++++++++++++++++++ 3 files changed, 367 insertions(+), 24 deletions(-) diff --git a/docs/tinfoil-direct-integration.md b/docs/tinfoil-direct-integration.md index ce689203..80d104c1 100644 --- a/docs/tinfoil-direct-integration.md +++ b/docs/tinfoil-direct-integration.md @@ -395,8 +395,10 @@ and `routstr/upstream/ehbp.py`. `TINFOIL_API_KEY` env var. - `routstr/upstream/ehbp.py`: - - `parse_tinfoil_usage_metrics()` parses `prompt=N,completion=N[,total=N]` - into an OpenAI-style usage dict. + - `parse_tinfoil_usage_metrics()` parses + `prompt=N,completion=N[,total=N][,model=]` into an OpenAI-style + usage dict. The `model` field (added in tinfoilsh/confidential-model-router + PR #385) is extracted as a string. - `_resolve_ehbp_target_url()` overrides the forwarding URL with `X-Tinfoil-Enclave-Url` when the SDK sends it. - `_strip_proxy_headers()` removes `X-Routstr-Model`, @@ -404,11 +406,15 @@ and `routstr/upstream/ehbp.py`. forwarding to the enclave. - `_compute_ehbp_actual_cost()` converts the usage header into msats via `calculate_cost()`, clamped to `[min_request_msat, max_cost_for_model]`. + When the header's `model=` differs from the requested model, the + actual served model's pricing is used for cost calculation. - `forward_ehbp_request()` (bearer auth): if `X-Tinfoil-Usage-Metrics` is present in the response header, finalizes with `adjust_payment_for_tokens()` - for exact billing; otherwise falls back to max-cost. + for exact billing; otherwise falls back to max-cost. Billing uses the + actual served model when it differs from the requested one. - `forward_ehbp_x_cashu_request()`: if usage is available, computes the - refund from actual cost instead of max cost. + refund from actual cost instead of max cost, using the actual served + model's pricing when applicable. - `routstr/proxy.py`: `/attestation` and `/tee/attestation` paths are forwarded to Tinfoil upstreams without model/cost/auth lookups. @@ -448,10 +454,37 @@ TINFOIL_API_KEY=your-tinfoil-api-key The provider is auto-seeded on first startup. +### Usage metrics header format + +Tinfoil returns usage metrics in the `X-Tinfoil-Usage-Metrics` response header +(non-streaming) or HTTP trailer (streaming) when `X-Tinfoil-Request-Usage-Metrics: +true` is sent. As of tinfoilsh/confidential-model-router PR #385, the format is: + +``` +prompt=,completion=,total=,model= +``` + +The `model` field carries the actual model name served by the enclave. +Routstr uses this to: + +- Verify the served model matches the expected upstream model. The comparison + uses ``model_obj.forwarded_model_id`` (the actual upstream ID, e.g. + ``glm-5-2``) rather than ``model_obj.id`` (the client-facing alias, e.g. + ``tinfoil-glm-5-2``), so aliased models don't trigger a spurious mismatch. +- When they genuinely differ (Tinfoil served a different upstream model than + expected), look up the actual served model's pricing and use it for billing. + The reverse lookup uses ``get_model_instance``, which resolves + ``forwarded_model_id`` values registered as routable aliases. +- Log the discrepancy for observability. + +If the actual model is not found in Routstr's model registry, billing falls +back to the requested model's pricing. + ### What still needs verification -- End-to-end test with a real Tinfoil SDK client against a Routstr node with - `TINFOIL_API_KEY` set. +- ~~End-to-end test with a real Tinfoil SDK client against a Routstr node with + `TINFOIL_API_KEY` set.~~ Verified: both non-streaming (header) and streaming + (trailer) responses include `model=`. - Streaming requests: usage is delivered as an HTTP trailer. Currently the bearer path finalizes max-cost before streaming begins. Supporting streaming usage would require buffering the response (for X-Cashu) or a deferred diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index e5c073d3..3f643a74 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -63,30 +63,48 @@ _PROXY_ONLY_HEADERS = frozenset( def parse_tinfoil_usage_metrics(header_value: str | None) -> dict | None: """Parse ``X-Tinfoil-Usage-Metrics`` into an OpenAI-style usage dict. - The header format is ``prompt=,completion=,total=``. Returns a dict - like ``{"prompt_tokens": n, "completion_tokens": n}`` suitable for - :func:`calculate_cost`, or ``None`` when the header is absent or malformed. + The header format is:: + + prompt=,completion=,total=[,model=] + + The ``model`` field (added in tinfoilsh/confidential-model-router PR #385) + is extracted as a string and included in the returned dict under the + ``"model"`` key so callers can compare the served model against the + requested one and adjust pricing. + + Returns a dict like ``{"prompt_tokens": n, "completion_tokens": n, + "model": ""}`` suitable for :func:`calculate_cost` (which ignores + the extra ``model`` key in the usage sub-dict), or ``None`` when the + header is absent or malformed. """ if not header_value: return None parts: dict[str, int] = {} + model: str | None = None for item in header_value.split(","): key, sep, value = item.partition("=") if not sep: continue + key = key.strip() + value = value.strip() + if key == "model": + model = value + continue try: - parts[key.strip()] = int(value.strip()) + parts[key] = int(value) except (ValueError, TypeError): continue prompt = parts.get("prompt") completion = parts.get("completion") if prompt is not None and completion is not None: - result: dict[str, int] = { + result: dict[str, int | str] = { "prompt_tokens": prompt, "completion_tokens": completion, } if "total" in parts: result["total_tokens"] = parts["total"] + if model: + result["model"] = model return result logger.warning( "Failed to parse X-Tinfoil-Usage-Metrics header", @@ -242,9 +260,15 @@ def _build_cost_info( output_tokens: int = 0, input_msats: int = 0, output_msats: int = 0, + actual_model: str | None = None, ) -> dict: - """Build a cost-info dict with token counts and per-token-type costs.""" - return { + """Build a cost-info dict with token counts and per-token-type costs. + + When ``actual_model`` is set (the served model differs from the requested + one), it is included in the returned dict so callers can use it for billing + finalization and logging. + """ + result: dict[str, int | str | None] = { "total_msats": total_msats, "input_tokens": input_tokens, "output_tokens": output_tokens, @@ -252,6 +276,9 @@ def _build_cost_info( "input_msats": input_msats, "output_msats": output_msats, } + if actual_model: + result["actual_model"] = actual_model + return result def _inject_cost_response_headers( @@ -280,48 +307,98 @@ async def _compute_ehbp_actual_cost( max_cost_for_model]`` so the refund never exceeds the reservation and is never zero. + When the usage-metrics header includes ``model=`` and it differs + from ``model_obj.id``, the actual served model's pricing is used for the + cost calculation. The returned dict includes an ``"actual_model"`` key + in that case so callers can use it for billing finalization. + Returns a dict with ``total_msats``, ``input_tokens``, ``output_tokens``, - ``total_tokens``, ``input_msats``, and ``output_msats``. + ``total_tokens``, ``input_msats``, and ``output_msats`` (and optionally + ``actual_model``). """ usage_dict = parse_tinfoil_usage_metrics(usage_header) if usage_dict is None: return _build_cost_info(max_cost_for_model) + # The enclave may serve a different model than the one requested (e.g. + # due to failover). The usage-metrics header's ``model=`` carries + # the actual upstream model ID (e.g. ``glm-5-2``), which may differ from + # the client-facing ``model_obj.id`` (e.g. ``tinfoil-glm-5-2``) even when + # the correct model was served — the alias is resolved through + # ``model_obj.forwarded_model_id``. Only when the served model differs + # from the expected upstream ID do we treat it as a real mismatch and + # look up the actual model's pricing. + actual_model: str | None = usage_dict.pop("model", None) # type: ignore[arg-type] + pricing_model_id = model_obj.id + expected_upstream_model = model_obj.forwarded_model_id or model_obj.id + if actual_model and actual_model != expected_upstream_model: + from ..proxy import get_model_instance + + # ``forwarded_model_id`` values are registered as routable aliases in + # the global model map, so ``get_model_instance`` will find a model + # whose upstream ID matches the actually-served model. + actual_model_obj = get_model_instance(actual_model) + if actual_model_obj: + logger.info( + "EHBP served model differs from requested, using actual " + "model for pricing", + extra={ + "requested_model": model_obj.id, + "expected_upstream_model": expected_upstream_model, + "actual_model": actual_model, + }, + ) + pricing_model_id = actual_model_obj.id + else: + logger.warning( + "EHBP served model not found in registry, falling back to " + "requested model for pricing", + extra={ + "requested_model": model_obj.id, + "expected_upstream_model": expected_upstream_model, + "actual_model": actual_model, + }, + ) + actual_model = None # do not propagate unknown model + else: + # Models match or no model in header — use requested model's pricing. + actual_model = None + try: cost = await calculate_cost( - {"model": model_obj.id, "usage": usage_dict}, + {"model": pricing_model_id, "usage": usage_dict}, max_cost_for_model, ) except Exception as e: logger.warning( "EHBP usage cost calculation failed, falling back to max cost", extra={ - "model": model_obj.id, + "model": pricing_model_id, "error": str(e), "usage": usage_dict, }, ) - return _build_cost_info(max_cost_for_model) + return _build_cost_info(max_cost_for_model, actual_model=actual_model) if isinstance(cost, MaxCostData): logger.warning( "EHBP calculate_cost returned MaxCostData (no model pricing), " "falling back to max cost", extra={ - "model": model_obj.id, + "model": pricing_model_id, "max_cost_for_model": max_cost_for_model, "usage": usage_dict, "cost_total_msats": cost.total_msats, }, ) - return _build_cost_info(max_cost_for_model) + return _build_cost_info(max_cost_for_model, actual_model=actual_model) if isinstance(cost, CostData): actual = max(int(cost.total_msats), int(settings.min_request_msat)) clamped = min(actual, max_cost_for_model) logger.info( "EHBP actual cost computed from usage metrics", extra={ - "model": model_obj.id, + "model": pricing_model_id, "usage": usage_dict, "cost_total_msats": cost.total_msats, "clamped_msats": clamped, @@ -334,16 +411,17 @@ async def _compute_ehbp_actual_cost( output_tokens=cost.output_tokens, input_msats=cost.input_msats, output_msats=cost.output_msats, + actual_model=actual_model, ) # CostDataError logger.warning( "EHBP usage cost calculation error, falling back to max cost", extra={ - "model": model_obj.id, + "model": pricing_model_id, "error": getattr(cost, "message", str(cost)), }, ) - return _build_cost_info(max_cost_for_model) + return _build_cost_info(max_cost_for_model, actual_model=actual_model) def _extract_usage_from_response( @@ -771,8 +849,11 @@ async def forward_ehbp_request( cost_info = await _compute_ehbp_actual_cost( usage_header, model_obj, max_cost_for_model ) + # Use the actual served model for billing when it differs from + # the requested model. + billing_model = cost_info.pop("actual_model", None) or model_obj.id await finalize_ehbp_actual_cost_payment( - key, session, max_cost_for_model, model_obj.id, cost_info + key, session, max_cost_for_model, billing_model, cost_info ) cost_data = {**cost_info, "total_usd": 0.0} else: @@ -972,11 +1053,15 @@ async def forward_ehbp_x_cashu_request( usage_header, model_obj, max_cost_for_model ) actual_cost_msats = cost_info["total_msats"] + actual_model = cost_info.get("actual_model") + billing_model = actual_model or model_obj.id refund_amount = amount - _msats_to_unit_amount(actual_cost_msats, unit) logger.info( "EHBP X-Cashu refund computed", extra={ - "model": model_obj.id, + "model": billing_model, + "requested_model": model_obj.id, + "actual_model": actual_model, "redeemed_amount": amount, "actual_cost_msats": actual_cost_msats, "refund_amount": refund_amount, diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 7183b49a..3548b4f3 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -64,6 +64,56 @@ class TestParseTinfoilUsageMetrics: "total_tokens": 300, } + def test_with_model_field(self) -> None: + result = parse_tinfoil_usage_metrics( + "prompt=42,completion=10,total=52,model=llama3-3-70b" + ) + assert result == { + "prompt_tokens": 42, + "completion_tokens": 10, + "total_tokens": 52, + "model": "llama3-3-70b", + } + + def test_with_model_no_total(self) -> None: + result = parse_tinfoil_usage_metrics( + "prompt=67,completion=42,model=gpt-oss-120b" + ) + assert result == { + "prompt_tokens": 67, + "completion_tokens": 42, + "model": "gpt-oss-120b", + } + + def test_model_with_dashes_and_numbers(self) -> None: + result = parse_tinfoil_usage_metrics( + "prompt=1,completion=1,total=2,model=kimi-k2-6" + ) + assert result["model"] == "kimi-k2-6" + + def test_model_with_extra_fields(self) -> None: + result = parse_tinfoil_usage_metrics( + "prompt=69,completion=20,total=89," + "cached_prompt_tokens=64,uncached_prompt_tokens=5," + "model=kimi-k2-6" + ) + assert result["prompt_tokens"] == 69 + assert result["completion_tokens"] == 20 + assert result["total_tokens"] == 89 + assert result["model"] == "kimi-k2-6" + + def test_old_format_still_works(self) -> None: + """Headers without the model field (pre-PR #385) still parse.""" + result = parse_tinfoil_usage_metrics( + "prompt=67,completion=42,total=109" + ) + assert result == { + "prompt_tokens": 67, + "completion_tokens": 42, + "total_tokens": 109, + } + assert "model" not in result + # --------------------------------------------------------------------------- # _strip_proxy_headers @@ -191,6 +241,7 @@ class TestComputeEhbpActualCost: async def test_no_usage_falls_back_to_max_cost(self) -> None: model_obj = MagicMock() model_obj.id = "llama3-3-70b" + model_obj.forwarded_model_id = "llama3-3-70b" result = await _compute_ehbp_actual_cost(None, model_obj, 100_000) assert result["total_msats"] == 100_000 assert result["input_tokens"] == 0 @@ -200,6 +251,7 @@ class TestComputeEhbpActualCost: async def test_usage_parsed_and_clamped(self) -> None: model_obj = MagicMock() model_obj.id = "llama3-3-70b" + model_obj.forwarded_model_id = "llama3-3-70b" # The actual cost from calculate_cost will be small; we just verify # it's clamped to min_request_msat at minimum. with patch( @@ -234,6 +286,7 @@ class TestComputeEhbpActualCost: async def test_max_cost_data_falls_back(self) -> None: model_obj = MagicMock() model_obj.id = "llama3-3-70b" + model_obj.forwarded_model_id = "llama3-3-70b" with patch( "routstr.upstream.ehbp.calculate_cost", new_callable=AsyncMock, @@ -258,6 +311,178 @@ class TestComputeEhbpActualCost: assert result["input_tokens"] == 0 assert result["output_tokens"] == 0 + @pytest.mark.asyncio + async def test_model_match_no_actual_model_key(self) -> None: + """When the served model matches the requested one, no actual_model key.""" + model_obj = MagicMock() + model_obj.id = "llama3-3-70b" + model_obj.forwarded_model_id = "llama3-3-70b" + with patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=5, + output_msats=10, + total_msats=15, + total_usd=0.0, + input_tokens=42, + output_tokens=10, + ) + result = await _compute_ehbp_actual_cost( + "prompt=42,completion=10,total=52,model=llama3-3-70b", + model_obj, + 100_000, + ) + assert "actual_model" not in result + # calculate_cost called with requested model + call_args = mock_calc.call_args + assert call_args[0][0]["model"] == "llama3-3-70b" + + @pytest.mark.asyncio + async def test_alias_match_no_actual_model_key(self) -> None: + """When the served upstream model matches forwarded_model_id through + a client-facing alias, no actual_model key is set.""" + model_obj = MagicMock() + model_obj.id = "tinfoil-glm-5-2" # client-facing alias + model_obj.forwarded_model_id = "glm-5-2" # actual upstream ID + with patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=5, + output_msats=10, + total_msats=15, + total_usd=0.0, + input_tokens=42, + output_tokens=10, + ) + # Tinfoil header returns the actual upstream model ID + result = await _compute_ehbp_actual_cost( + "prompt=42,completion=10,total=52,model=glm-5-2", + model_obj, + 100_000, + ) + assert "actual_model" not in result + # calculate_cost called with the client-facing model ID (whose + # pricing includes the correct upstream rates) + call_args = mock_calc.call_args + assert call_args[0][0]["model"] == "tinfoil-glm-5-2" + + @pytest.mark.asyncio + async def test_real_mismatch_uses_actual_model_for_pricing(self) -> None: + """When the served model differs from the expected upstream model, + the actual model's pricing is used.""" + model_obj = MagicMock() + model_obj.id = "tinfoil-gpt-oss-120b" # client-facing alias + model_obj.forwarded_model_id = "gpt-oss-120b" # expected upstream + + actual_model_obj = MagicMock() + actual_model_obj.id = "tinfoil-llama3-3-70b" # client-facing of actual + actual_model_obj.forwarded_model_id = "llama3-3-70b" + + with patch( + "routstr.proxy.get_model_instance", + return_value=actual_model_obj, + ), patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=20, + output_msats=40, + total_msats=60, + total_usd=0.0, + input_tokens=42, + output_tokens=10, + ) + # Tinfoil served llama3-3-70b instead of gpt-oss-120b + result = await _compute_ehbp_actual_cost( + "prompt=42,completion=10,total=52,model=llama3-3-70b", + model_obj, + 100_000, + ) + assert result["actual_model"] == "llama3-3-70b" + assert result["total_msats"] == 60 + # calculate_cost called with the actual model's client-facing ID + call_args = mock_calc.call_args + assert call_args[0][0]["model"] == "tinfoil-llama3-3-70b" + + @pytest.mark.asyncio + async def test_model_mismatch_unknown_model_falls_back(self) -> None: + """When the served model is not in the registry, use requested model.""" + model_obj = MagicMock() + model_obj.id = "gpt-oss-120b" + model_obj.forwarded_model_id = "gpt-oss-120b" + + with patch( + "routstr.proxy.get_model_instance", + return_value=None, + ), patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=5, + output_msats=10, + total_msats=15, + total_usd=0.0, + input_tokens=42, + output_tokens=10, + ) + result = await _compute_ehbp_actual_cost( + "prompt=42,completion=10,total=52,model=nonexistent", + model_obj, + 100_000, + ) + assert "actual_model" not in result + # calculate_cost called with the requested model (fallback) + call_args = mock_calc.call_args + assert call_args[0][0]["model"] == "gpt-oss-120b" + + @pytest.mark.asyncio + async def test_old_format_no_model_uses_requested(self) -> None: + """Old format without model field uses requested model for pricing.""" + model_obj = MagicMock() + model_obj.id = "llama3-3-70b" + model_obj.forwarded_model_id = "llama3-3-70b" + with patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=5, + output_msats=10, + total_msats=15, + total_usd=0.0, + input_tokens=67, + output_tokens=42, + ) + result = await _compute_ehbp_actual_cost( + "prompt=67,completion=42,total=109", + model_obj, + 100_000, + ) + assert "actual_model" not in result + call_args = mock_calc.call_args + assert call_args[0][0]["model"] == "llama3-3-70b" + # --------------------------------------------------------------------------- # TinfoilUpstreamProvider From 956a1ac3e182ed17e897f6bdc17186770e023193 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:56:40 +0530 Subject: [PATCH 23/27] fix(ehbp): harden model comparison against casing & date-versioned aliases - Case-insensitive comparison of served model vs forwarded_model_id (matches get_model_instance's lowercasing semantics) - Suppress spurious mismatch when get_model_instance resolves back to the same model (e.g. date-versioned glm-5-2-20260415 -> glm-5-2) - Preserve unknown-model warning only when lookup returns None - Tests: case-insensitive match + date-versioned alias resolution --- .wallet/wallet.sqlite3-shm | Bin 0 -> 32768 bytes .wallet/wallet.sqlite3-wal | Bin 0 -> 428512 bytes AGENTS.md | 87 ++++++++++++ TEST_SUITE_OVERVIEW.md | 180 +++++++++++++++++++++++++ routstr/upstream/ehbp.py | 41 ++++-- tests/unit/test_tinfoil_integration.py | 80 +++++++++++ 6 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 .wallet/wallet.sqlite3-shm create mode 100644 .wallet/wallet.sqlite3-wal create mode 100644 AGENTS.md create mode 100644 TEST_SUITE_OVERVIEW.md diff --git a/.wallet/wallet.sqlite3-shm b/.wallet/wallet.sqlite3-shm new file mode 100644 index 0000000000000000000000000000000000000000..43eb655de1b564fe464db10a5f0c4a5dcaa9c1f8 GIT binary patch literal 32768 zcmeI)$!b+W5XSK@gHfXhA3(Gk zPD67*@hO+nN~rG4-+W<v+;{T||bg>qkrL zv_adoLkD$Or*%%3bjOdhv(XaRy})Ys8Im9Zxdhf|t*+=!uJFV~z_GwqU3Z)vsUeV4 zV4H5_3{PwX91Bc3&W+R%$SZI(Z+N03;8@_e=ZZRrQf1=jND*4>POw_2@e0tNSfmc^5ymu4- zZI;h8AM*Jvt$*;lI9|uU`#s$>@X{wv?;82~z^0LV2S)B4`E1&FBBA^H;0*Zhl zpa>`eihv@Z2q*%Iz&b(TiNWE~0|y4r-PMTV=`4-UW()P&+-!aE*`~uMs)r`4mB~Y| znW$D4U#}e8QE5KXxH4IN!(`>Y6Gx67I`LrTb=3#Q+kb2xv2x_tWaZd#{yQ}>QT%bt z-ZV6N?}5RA+H9IVRzE$>ets%C)0iv%J+=5vQ|97x+1GDtVk`UncD&&K-Zy^R+YbDj z@7g6VmZ1cBrVm9x5l{pa0YyL&Py`eKML-cy1QY>9KoQuO2uO@zbE}Tv{a^Zv{a^mQ zLpyiKwQ5{oW8QJSDn&pMPy`eKML-cy1QY>9KoL*`6ahtG5rHlq!56N7{-!&A;&&fd zfk&`)@#6)rV9h*&ExW^lN3c5_xvArr`cx561QY>9KoL*`6ahs*5l{pa0Y%_yN8sH5 zuz7fN-@d^Mqebcb{KDK^UN3)c=_;8oU+XBBZ`eihv?;SrCxi z|JGJq;QeR+%g_DXEkoOsM{rrN)q^Mkihv@Z2q*%IfFhs>C<2OrBA^H;0#`c%T|9yt zUipvS@}uUHx30h=5FdlqL-YyO%p(}ubwis+u9 zKoL*`6oKakfparkhevO_ZScZ@jsp3qGxaQO){e{5p+2SZ#e3FYy}Ua#G`eihv?;g%Ob4|8OfVuyw~vcYNVnfBZq^5nSQBpkJp5C<2OrBA^H;0*Zhl zpa>`eihv@Z2s}3k2#=s07x;1a4O9Q+b4Tn|@CbxMux1{?@J^?TN5DVzp$I4fihv@Z z2q*%IfFhs>C<5yMfpc#h9^Siu|F#Qz&epO=<7hgXO|s^%W_|tZk5nJ1G+(M5I@zW6 zezH1IJv>=CeEiTv_2l8|!IP(s9;}azSBx><+5XTyCypQOQ@>UinxI27Opuiu9KoL*`6ahs*5m=81oSWV@ zy!ZCow_UiUW20PbpPF0a;(4DfbMgA>_RYfdE_b~LyY{oP8D7Jtxj2KK1Ff)g*0_M` z3#h)pM$s22wEkK=g3mws%2)pX2fuhq^aTb-{_6n$*M}mY2q*%IfFhs>C<2OrBA^H; z0*Zhlpa^Uv1hx-u-naeHXnHzpa9QFf(B={R@nf%;{p7oU{a=c{z~IQ&2Kc`|6ahs* z5l{pa0YyL&Py`eKML-cy1QY>9U_&9Wy)!P*#Uq&fwV(d6k@vj&wpH*5#Lr-DJc8Zf zz_uNy3Le3ZQzJKtPvK5Sc?27(sp-Wi0*Zhlpa>`eiomsvz`2>t!=wB54PJ0Ntlmnd z@|HdZZ+&RwQWanF$16vUO;(N_=f6`E6XQJ<@AAgOmC5QGR(exCOBS-ml6O`XvU;{~ zmdgCs96!#{`yT$(u_O1Ns#Xq`M;PmU)0pxIR9^tox`N`-%kH>BZMnBy$``NPc-QDt zIP&#@FBg4$^eXS;YsKY`EkmPs?OVaWaU5;NAT5wk*{9c zQ<+}9BA^H;0*Zhlpa>`eihv@Z2q*%IfFiKb5ZD2YV6dbSNKBxsUf{bv`@K)SYh>RY ztEd+cpTlQRFK};JFL1Bc3v9G@rq`ngC<2OrBA^H;0@ox0=bmg<2;6^_XAhTD2vq9X zY&um-i#h_W7my6IJXCk^w!sSrI+}7(3(%p>ueyU;FR)Q`2fN||U-{A}|K^WJK74!? zae-~jc+Vg%aAz48xKrZ-*W}7UFIo{$1QY>9KoL*`)+++%Z!Gu!PYrf#n$`hBpnLyc zY1C%2DR>1@0}8?9k)zd5%$`A9;Q3`-;Q1OCSg(r$J#R%o5l{pa0YyL&*vJT+A8&5|?TxtoFT(;{x0FsP5oK zURCI|DFTXsBA^H;0_zTe^P9E~7wN*+b!>^(K^bwm*)62cp7};s;{vVBIlotyZd2XC z4Wv7WNcvxEy+C75;{w-6T%b#L@Q;4`TDgI^E9b9)S7d>G`KoL*`6ahs*5l{pa z0YyL&Py`eKMc|SU=+GV9%m=#S0{_tdrLVnh?1%nd;{ulirM^iKPy`eKML-cy1QY>9 zKoL*`6ahs*5m%AZZVXV2UI;B&w6=kI^_->twS5KV#B<2s(K znMbf=_$@s=g5kI9`fTgq`dblD1QY>9KoPiB5V&x|_Tka}`v;$XvM5=9BzwG`HR{b@ z+q#V2n=dZo^xoD}@2GxmKb@(~Hm1%jOgAOh+pl*#IU7ya(y4r5Ze~%0t#WtuotFie>p z{}@)?!HuFjShRvxT;NCE_&xvNk9Rd*F7*Nf*UJxmC<2OrBA^H;0*Zhlpa>`eihv@Z z2q*%Izy?M@a{t>~aex*MNClR0vq_Q>!m3Iihv@Z2q*%IfFhs>C<2Or zBA^H;0wn^%BWTA3?)&fy&wcWn|MZ`(f=95E&##R~u;cjyJBIEqcmzXtkKEMqBTycJ zHcdr95l{ryHv;F~rmI=CL&AN-n3^x^YF25N?{}5iwknWUEDfKB-KY~|Plt-`*3`uqixWGLx`_Lz!xaWCqT18wyC0X=&~ zKoL*`G%m1YcUSyV;{q#66I_k@0&I!D`gj^dA(@<%P7D^GjnHV89KoL*`6ahs*5l{pa0YyL&Py{w6 z0v&z?Te-q6KZ4)*#NGesbmLzRu23%^9t5q&Zl0{UUSQ{zPqldjo%I4+KDGTjtuN5u zihv@Z2wXY>7dBrvJbLrZgHJzLq^)YRXXk23R&V{@(WU9$dbLkTe=(e2y|;SeA|d_x zg(c;?Jw<8{NA-u7k$#_F$Z9juL)mhFjwnoj&GF+CZF%*34o#dyUwvsdx>1|SrYI$j z8dC<2Or zBA^H;0*b(;BOv+$?YO|PpTF;YKl8#JKc)Htmkyx5QxQ-E6ahs*5l{pa0YyL&Py`eK zML-c)PY87A3k-4IUHSrlbo$l*>He>syl(}40r4hmJw|x4W_^KO7tt5kzR>y#{jCTn z0*b(;BB1&LE9eBYD6p;HsxOc&oJEyEWE{rPFP=@OT5f}-RC$kV!?^ks29{w4uIcqE zT}ZP!8%@{Jsm9zYTcUjQYV;#OUm$u`^##st-!(isIy!jnbfJkdJ6CTk6hC%$si71v z^^rnpJU(9vlw3pwWjd;3W7s1gBF=|h!Xaa-FMzuI(M7tgyixrK`p+`Y0GF;IPcW9p%IZJYj8CEL@pra( z1W*2p|8eT0|LKvRRDFR<-Gk`+6ahs*5l{pa0YyL&Py`eKML-cy1QdaFjX+mi;5)xG zy7jL<{Z}t4c?91W82QG!zBWC1ML-cy1QY>9KoL*`6ahs*5l{pa0YyL&=!d}8!OeRH z(wIMqzCgQP;Ji(*IFE^?R$(7r36!uT5WI_$>o{^#wk=>$6)vwf#G)FR=dG znqGk-aODwDeStnSV$)xq5U%`eihv@Z2wVvSI_d=^ zHqccs@GJH|yYq#kpZxw+)C=t47?-zR;ODen;7YUu{SHN7V6F|73hGR!q0+4{ub@aTa9gXiul zX5}CGuD5OZ^a=miji6ci3op`YFy6S_AHG9n?%5>CT&=mc4 z%-%FKdhdb3f!b`EJyt(GU29}h(V50v@$ad{Z<;a}pLf;^bj1b!)vG@8tKT#Ds(-kO zxWEnUE!Gwn7`l6rM-Y$Pw8P)MX{U2tby(v98*{s+SEUGC5d<_Ya5cmQsE7Y9DJgua z+OMSW{OdLk7k@2%*~+y=`Sa>3IDD3(29-2hsGW@(EEA^YqsGIRQj1#AGr1UX0e3Z>x}GS(1Y8SyDY5)mvM?)`wcZ=NGctO!QD`Ki=oq5j^qN z96vtMPEFo(XyRnG_l<=}0O|0k(MgkSf9i_Y3us)RU5C(4VK0)FFLR66dI60KtTYBq zM|DbNjvTF?lzne|V2~lLYrI-ZTww6|GN+!uuAH1MylEp%P8Tl(Sw2{IZhG7B-rH~A zcHx$@wd~P2nvQ0ZteC1hpEoC?*B_}qQ0aWVa_D5&T{&5us2-lI96o+%qI&Xh_29`< zM-SG=#w*4c@2nKs2t~sGVArQ)l4*WYK2XkR#V6`L@6&pL6V=w7|Eh}7g^$(?bfud+ z)|wg@=t#piaoH&K0t2;MjLM%)yk}4S*Z=-apFi*$zqUeLV0XvH>Ulh0b6jBLqH%#8 zZ@%t@9p9u+6@hCW0gVfsI(Fp#Q`O4B zTp-Dsk&ZsWkJZIJn#U=*4wohL(Q{)jUJaoC<2OrBA^H;0*Zhlpa>`e8yNw~{qJto3;e4Oy}AC? zuYBy09dgAQ7ud-6U9U|MPy`eKML-cy1QY>9KoL*`6ahs*5fB8#r?4FtFo(DN;)U%W z*`_{)+Bg&eML-cy1QY>9KoL*`6ahs*5l{pafsKhkhfm>lu2B34+Hrw5oVn%b<46Dg z{3`SX_VW2>t}pOFKixsq7uc9BP_Jt32&lfmA`w(P=lZI%tL|X0yx4Q0FL2`$KZ5hG zFHH!Z9`4X3D>-@Bun9pWoz70HA3>#}`U0ba=SqK$*|~aSq4=@0i)mlH)Q4){cznJT zE^WJ2^$L?do4SLlFVHjqQ+*!UvdR^E7csXT_fxb)H2 zgm=eFb_|Uk+P|C}#v|F|^{j!jUh~QJ-Qj?^*AKWuL!(D-Tkd13FR`eihv@Z2q*%Iz}1XEN4IKB0=9$+E9MO7#tGUhSxm_&?XuW{e3#=QT!mXp{(`JRhZCW8PA2pg@ zl^qoVm6_UXV+t!u(N!N`t0me6^(j=J!j*E0S})L%mF-Re_#b@&txPHS8s?Po`*ae=<8GUX8{j{t)|DaBWx z!qxfs6`Rj{P8>hlr(*qNb)tHBvU2j&(Szkibc|zH>OEbs_;|-=w&x8R7r1Tk!hw#( zZV^7|Ky0Xs^V9K%K6gdq0&{u&ImD%>b!%LpzL~vW!6M;zSG~Z`{PQmk=dXX!7grG% zcmc;)TfM;M4-AZS#07@lx_i@3=ep|f&+Yo`mQQW}&h`b>7r5j-`g8F{jSH-5&3}2~ z0z-EXzG(Ee+XkQ7zP{$CN~1QDm0Y_v_kFz5%9B?5ZlBhiJym^yHpZ2^tM?o_H8ELv zF7yRnwzjyy`8&4`7j69Zjo-$#UO?7kEh51s7Vx-@t9hmRkcsGdAr zJt*7!`q+5ID0cZ87g%lRqYGRae=Z*N*!9hUPsp64ae*{@Y^o#VG1W|t7i+uWk*^PY zSww{{jCM@j<-4yg1);UBEw9abfe-%D_Fvxq_LJ{fMOmVDmmR8*z3AjAbLgf+Elt-XEf{EVYb7nT1nVYR8OTp%|Ht`;(;ae-_qI@4&XYie9z!|M)q)eHQ>pN>ub+`x$kR}mMu=~;;j{HfLpta-nF zr9P-}f#)DDu;b3bd$eBQRJC%j3_q;RkKn?t>xM^fzIpKJnPMMWn>{;MOR{?F_l~Z5 zz}BmMiry9pg4KJgCpze>U4at*F~6{MVY>7k4@dR&A33PZooN(7j6O{H*1}sZbje*C z)3`vy{@j!dVzoP z(+j&__VEK#tB4Eie}-{^O)Y(aA?gLT4DS46;SpR{)wsZw+TO4Edo(Wa9K;2-{OQ0$ zS0FBM?)wW7p}~G4LIcfR^XrdPAE-2qh*rmjY<=kR5h8n*c^9t=B)alL8W*UZoIG^& zzIGkLWnda;T;S+^E3sp=UcmHv^SZJ%Qy#&3>SeF}Sh3NP$w2A|Jdq2O~oH{CGJ zmBK^UbO8Fq@pCiVhWFln`{0FJ&epO=n=6r`G_CV_zco(Df?RL(4)rPQTfeG2f^4B) zo14X=GL6qRpD(K~@Pgm@k1zQ1uextqMO@&8&oC~~)*Y0%z~*1y`gVy6?0mV#1=hS@ zzfvF6xWID}7x>=>zUR{70vmp7Kk)CE-?v?Se(w{)SP#?twYGTCdI7B$(73?p=sJxH zTvye&KyO@t((|ts!RKo#E^x(5@h&YI(o!<&Ng%8r8sDV^-D~O6Qap_dXk6g2`swLf zqmu}-7z?`eR|J6z-@AQybpQUrcRW(8 zj~>Y$uV;;V^Vha6wSwl0eZ&f6Hd}T@BY!Nc?$0btH}{b8($Ys+ik&aa&8*U@P!6=d z>$odtW^0X0*nIg4W1wM?1vbN9JBcMp&5-8*=`R7+e3 zdzy3GcMY%G-KTCsvUHWbx-fw$luIvaP_mebyt12+YppD5gO~8RYvZJAQ20`2t>wjK zRz-Q&WZrX-%(FKQjoy1;xfR7Sx%(#uher<_7(7?Hz%RaO@!6)Xh2`Sw9c#-pF4|V* zz7t1|9y;-0<#p8u7dd=Q`atE#vB|dbN)d4yW6`{c|901IF)DvH@t!^3{FNX1(i<0E z@xSik#lexU4)A|{C<2OrBA^H;0*Zhlpa>`eihv@Z2q*%Izy?BK$Kd9DgO5hj(^=y> zF00Fr;G^%l^Nnx&$-n%eRqzOI;TT&w;F064^w>1;(kD(!>|tcY;e4BkLgdP9BZN=Sw@yc6wnk`B1*2QhD}N@3me)>jhdmx9dS9rk5_I z^#Y~SLAU3<)(dF8z(Q7*`Ylc@l?6ye3uhVf#H4w=QqN}7sa82<=?|PNWKpAYOdKy? zZ-27ALRnD@IGxT;Hy0j7Rc8B(X0qu<`)k)KR=Do#1%B-(hJNwY_kH>2wO-&k*mLMt zDgugtBA^H;0*Zhlpa>`eihv@Z2q*$q0D+Erft`F^k>79e2u@iDo2?+e*w{T2e1C(7F4Uf9pdLPy`eKML-cy1QY>9KoL*` z6ahs*5l{p+8UnKWzrGb0_?=sC{`>jqpSgaAT&2bZHrl<`>rn(00YyL&Py`eKML-cy z1QY>9KoL*`mLkx_BiQ=lzxmIP{{CI{6?g>C>o~{(p09~Vu$2!Ej(kfF^}La9b@&u^ z=?)5yVADG`y+hunk82Bo!#7?^1-O)Am5hMJ3h>3(ZcrY9@(7ei(5g5ue{UE=Le(8C zZm9AIlB~X?i~UubJv&$9)sC9VRv%CvK_TF2`hjJbfopo>>Qh*qs2-lIoIG{(pz;V# z96wqqxe}|SNvKcZeJcvwJqxOK$|F!7!9=xknS2V_V%{iz1bbw!*{T;9|I$aE`u&&u z@^_R+AgJg=5l{pa0YyL&Py`eKML-cy1QY>9KoQvR2;3l7*oq5$`oNc8aO>A=zpgxj z4S(PD0u=#8KoL*`6ahs*5l{pa0YyL&Py`eKLEuLD(TWSa;KQGD|G`Io@sqoTzCCc= z)_s@Y6`(c#f}cK~4Fr@&a1p_O^&@E21@rq^_ak6DR6ha%Jm2_!1kl^w|MI=d8CZzs z|CA{|cb~g+_weZ6y@Tfu6;>zmn*6t`tDs$8=~K*Jyu5U$|8<7srg`W0wak~ZO5h#zK-Hgg7hnLP> zOa5N`vz}p0wbWUwvXIrYg|keauQ`5vV!Tq%X46F@hhkuB+F31*ZR$sG`TPh(E8uyp zxWIS5yzd3u)4#hzcm&(Fzjk2ccSjEIe(&ywcRsZ9*6lyY3;Iw56ahs*5l{pa0YyL& zxF`abC9?hW4L1}{yFYNe%V~E}L#O4dy7Q{EC+Xa{`7{k*%g-nnyV^zhBgWjb0?JX2=t zndl2_;=guW;O9T~C)q9kV#nJhE-*Oq)dBvm4@E!`Py`eKML-cy1QY>9KoL*`6ahs* z5!gTo>=@j1* zMc%xX+-Ut)KZ4$Lukr}$W8)QLta9jN`gJc9V+ z|I5v9dcj*>v1{i;1KWnj+dP8dape(QJ^N)n6GcD~Py`f#s{w(h_FZ3;dB5{bU1i>j zL8Vq1_u?xfT@!lm-#aFDSzO6O%e+e*+CQ&s;dXbub=SUf98|iQUZS0ASCa* zWdGHxX6PK#5d75{f@fXT#UuEm{}}vE^vR)bE05r6SaIq(C<2OrBA^H;0*Zhlpa>`e zihv@Z2q*%VAAt@Y!ETN|DBsZH5&VY-_W!|e|Jn!tVg(+-^E=M!Hl8bwpaT$ndbtp| zEZG+25j-c^ma8WwtvrHO(xLtPY&2a^Rvi zGUC0Nt!GaRNqGbm!nZ4y+cHxZ>#@osP#!^R)L#Qvq9-M-egqv!{fhDkIttKxX98(> z$|C@Tb;~1Y#|6%P`^kGh_P+2Y;SucU!z0-FZ+E`!a_#!`IEsKGpa>`eiokkCKzRh$ zcHmKY1U=iwnXK{f95IZG*G6CEPP@t@U`z9C^9TlB(2qxO-`=6oJMI{KuwWO|Pfynx z*;G9{eI}d5kF@)r&+npSE&1cY*=Qyk=aXX>p7*@r(b3VtcMKQDoPpC-FMjOp`h4+H z9~PHvaWVUBx<6Dp*xHptrzVddImS_rR*y~g-yEM=m|m$b6kjl#6RE#=pON>SnNOp} zszZeG$tVH8IthVCvf1LB`k(JYmSe>6@QUw4T46yDzR02Gqk8?(xrK5p=ou%JM^L$H zc?5f9P1dRx_`~o2?lZ6c`OQa!M=&5_Tl!E06ahs*5l{pa0YyL&Py`eKML-cy1Qdad zjDT$NEYihv@Z2q*%IfFhs>C<2OrBA^H;0&N6D zcd#87_^})A{n$-6-u4;Q9c<&HPZR+~KoL*`6ahs*5l{pa0YyL&Py`f#4TV65?%)U) z)}=dm=4-eA%C_%}{rDy54(@Dz_C@?w-N6mjLavsJQQg6|t!OV1>?&txs_r1pW%aC4 zU!JWfRKni-Dm*};IZm}$iT&SFN(bf(b2F=yB9r5*?qCbyu^mc!oU1n$c6QNhuTIjV z>JF}&RIIv#syiqHzC2WSuxF?jSe}70p6U*&?jSAb)UhM?pQ=_4*0W?GYxG-D*qeq% z?>#U$P@7G&$C?@#(V50v@$acLKASDnYjd;pDRc4p<|hV+M-LnrJa<>2h_U#G#b=wk zChf)7JC>YjTq;&{C}J$GC!1d@vKj4-#tt@rH(tjN+26PI1;*wc{wI(A?%-4Mnmk6n zQU2DSihv@Z2q*%IfFhs>C<2OrBA^H;0*Zhluz?WRI=FeyKpJyJ&*Q%~kKkWFz3nZB z-h1bp#gAZn;YYChy}KVC9^ZNE_8;8-T5%&#KY|U^3iMJG0YyL&Pz0U<0_sPgegw6% zR0oxrnY*j^96B{ox;TzqmWsEhZ@6JtRNH^xc#%1Z=cXH`IW?0_H>OUXnQLVA-dFZ? zWs-XT-jPv~oT)sDLtP}fRKBaMWh&pcyjP|A5%l-)B%dfs@>7y%Zi(A3rtew^&qrb+ zd(pRuSAx9lVh4GACZpe8J|5iUt4&5`NKTBegq?j)sLWc*D*X$KLRnH zSWKkX*Rv%sD~flC1ID@2#i(7B;1xgCLuno--=#0`hQ@*Dj&J|4@ zKoL*`6ahs*5l{pa0YyL&Py`f#s~CX}eF1SIEUX7wegyyD`(AhbJB9{-zl;lv4~&do z#TTNdrU)nkihv@Z2q*%IfFhs>C<2OrBA^IdK?Kg-Ke+Y$u5-7ii62L9WV)7b*nwfY zzU??(k|wU_Sbh}7rtP^#mYJzu;mV_L|N!&b4T*nB_FthX2i$Y^|ZZ4h9DoIwGuFXDFLEx*lP_M+( zQSwM-KATNy)0Mo|sMO_;4``eihv?;RU>fj#E^sq zwr{YwK$joEXVTCA@=tv3_ZBX}kDzt0)Q_Nrg#NzF2wWC+=7qOjU$8UZ`Qk2irYOF5 zvojZ88R^T;T>QJTGf^bKhkok8FCG~hefi$y)PvVG>G9n5UBl~^^r(IW>PJwCqv>ci$)?WK z0prlgE{XZ3;{M^|hbF2g4_6PKJazP-`Vq87Vf7>EYAh{S)Q=#q&o9i)<@GIH%ii*} zK1*Ezj@&Y{v&^>_Spv!%p8>iHSv^}gOD~t@!g!^g&8AZ*YLxSSVFl5Y>sT~o8m|~xzx# zN3cycb1fc$_nFttO@8ow+X^1Rj*krRe|;zdihv@Z2q*%IfFhs>C<0dv0v|m(xbr3V zZ`rbC`{#FUyK$Rgn1&zPei|D|5_s;zjmCWaWp~~wOZ}JfqZZd1^_M15{oy-bQ)?vn zguU56{fDjpp1jq(^<|Y?4Li=L@ilVCvhpB}JuCF{*fz`{PVCIHJl{3>9>;bPGfYCt zf5XtoOiF?sJIDP~p+-envvVu4_b07yByhagPf{ncGAj=Q z+jqU(3c}n;BHuTo*mJ`?2;@*sc_=3~sWLYCjx6^hC-MC_@I5C;vn+6YlR{)K$ih6% zv&4x*5O5t{aIM(4(!lT|IaE+yU>4PDI6J2cfGGAzq9(m3PxgsEd^ax-YcP9B7g z6+0BYJ85y5hSh9iR_IXS?zo;61Q91{^Y4smcrMtnLvCE2xUO$Hz(sSUHa?|dp8H}Z z++;U4~<%U6+8mX1|xg7^tl;*U* zAhU~RMyY(Wp=I`@l{=nqaF=YldX$H*;~Pn2S%y#Z%QG(yBThdIoYapz8X|YY@iMCUa|VXA z?Bk|q2hG-I7}D&`JmpTtre~R!Ck%kpPm<80xf&)ngpq*GYi9;fB?)aN2tvIcZ48Gptyy z)pK3D30=&nX!u4R`MDL+DH*KHG>TI*3+NK`C*Y(R`nj9B7K6KZ6mFM6z7-cZklecE zAOFf@KU4Auesf^tH|4MTPy`eKML-cy1QY>9KoL*`6ahs*5l{pafvXw;Z_7*W-@f{A zDc2;ifwsQD=YHcq&wc!RZ#ya1N_fg~C4?hUrZD!1+!Vnjo0x1fzLy1&W1*!G8-{Ji zQAE_x;C~K*(9Fxy%&`c?xDFw!F!Y_s2vg#76n(~78WI;Oq9hi<6{i_>A*PrRtO*Fw z5Ib>v;x{oN%aHeEs4#?)=lZej6WvLII1FOj&P3ee1>2GHb1jEpR~Z-eEzdSQ z;-_*ImSKwMgW)D=NEA3A2=&heN{ESn22oZ3Xc0!_q{}lT zToea(j;=(S8HBos?Q+l*AYuYbgo1#Q$SCKS_&`Z8H8%3t^FlYW33nFZKF16RNj4#L zC2SYlrkw|tlSX-vlyOdrs8q&X36#S%k5|EFdguNI=xK3D#L&L?AKEti&L$m*uX+#ogR%#+H4AGg=X5 z;>xL!TilA&@Cb(mrbm=JNeN+bs~rCA5Lpgl&fXz%YsLuy={ztZE3{*xaB`Pz+jSkH zkR^&nkUO@SN3IbXp`8(ScU&T}nH43O>r3M${zt&m_6ZO3Md*6i5v_>}L-->q0{otD z8eDc0#xzX{&08s#8;BL4nQDG~$C0?8p5x0p#yQXbh30IPE)kG+x#30-n68onc z0e8_~Y%lQ9FKNb66GEDV*Ha_TbATrZ8G<0qNzxJ-H=lSt{VC-|Zn(?8eN5AK~gD+8Khuh z(k`MRK+YgRs=+2dQXZ453<>4hY2bw>*^Q84Cn5t9aRUqo)M;;tnWdJUx(OJk4q@wn zTW**{hy7xx;G~?;qb)akcTDa8@S{A+0>0a~V>_qukmTS*377|QKrsNC<`#Q>ULq zWS=}YOSvj9DZ@Z^}cTBJBM1jv>TAQq6mF-{jP7I+|o6NM;yOz-i}6Vv^lpQ1Sti5gpXI zT~164-ZfG)j1x2U%6{a7edyX`1jc)1ZX2snh{(f3K+)c4%TK%|GBLv!~n zmsyE{i~L4R%eR?SxDi28+@plQ0VXggl{R z=42B{L~*mjIAlQbX`!@%Amsj*c^oICQ-B&RxjbqvgNTO?5&425K<|^2VCr$mkHn5e z;GYv@uE`ktC0RzU!}C0{L=5=!zGgy)#F|MOf?>4T^>ZU7-xDxqk?vvkEtr0h@EBDYRxQ zIus4xivlAh<7SYx3Q4unrI=<+Zk9`;G4zt0R?1Wl6p`;T#~PW(g)*rZz04^_ZBCXv zQ)|x37!dPZ+9h9WMsDCTYSVmKkkIQ)R!K}QF~hnnURc5~d6Q(PxyQ^7QC1{yE#`Bd zZXpva(^`4w7!GCHj4WC!qYmIjwiA0HgA2oFV4E~)CLeP0jCu@*+-8fJljR;0xtA3~ zEm=**Zy6UXlMj>!CU4Em%6Cf3b356bCC=*R*n}36nQ#9n!Mg6#6d>DNQpHgC4UV7?20$e*3Ka(nv}If{0E* zJ2D+sugnoek0aOYhauyGN6Nc3mr2splqFeC^OmeG8PLdb=(luN<}$M9r2WasGR9MI zK%GF48c7;4d8I6m%TDK_bQY3+hC$B>Ei$U~-R8W;2?GTyH&z5r$ZF1|2_|%fG(l9& zOeIw+qbBQKdN>V*(TJJEBi&DyQ-)l@Ibdie4b8B?L_==2JZZ_x(~UE38ygTwoYQb= z#Tnh!VlsiuU2$`N}fVAPz^rQo@f@jSq12yv=*T}JG(F~MPaT94(Y#B_F!_;Rm zjkM-Fc1yBIi%c`8;(>z$GSeM<4pPZn5GneQRVO0}Q#uV^`YF3IhHbV1p;Zp{K|rUV zN$~|VQbv#x?GOY&ob80n`AnN8d%A@Ajs-PsRW^5?!#FDK1t@Zsz$^n<;!y0uvv21D=%FdX0Yz1h0E{zn3+1Y?Xl2aLzMI^)1kfxD4QarC>(EW^3^%&4;bU=PBIm$nkhw!w`;N?zDuCdHzNyK2kqA_ECSgiE_CF=r)guykgt z1ZHl+%AXZV&RobKO>1SkrCKGEGAI`Mh$1p}v3KFGY!y=}EBc@6QG@M&m%hNw=N|d7 zFTQ*5eJkh-j7r>~`A~g<783gVG9#e+0vBO#EubQr)k~iBMc$S?oL$@Y;$w&R42?cu zE=Lz{d3!IaM@*T$uZeL>LAbgQZ66-pzkl$Z`wF#+riMWC*S0Q#p6Uw-!LaM0`T}h@ zM5dr1G+w0d&dg?L1lN+K@9W{owv-d(plByFKQ`9$zA@Do;07#`V^`2fDaV1O=(lfS z^GTb0y7Uh#SjQLs^IcLK_Z*ryS?!f->CdHY3vN(LO3EW}O>dcx7GKn++V=ZgW$sL) zN#mW5O5gj3DYh@JypLE%tGU(ECGs3&FWE6PdT9T0V{LOpoHCnFwx65XHoW)t+XpY) za<-N|+9@5-`Fx-`tiJw8^#RovI6PTdMnIv7S>>J+$B*{mg{!_m&v+<3_8BnXi;h_c zU9@}GibVEjfxUjmmZ8zR_AS@xN}Yfyv;4jF{KDqp(S7>{pT4VDz?83+KUH6#`i9A- z^iXS1uc*GjpG~}HPwjhdNsj!@pKKO=flXWQLtlU&`cMQE0YyL&STh12J-Vsn!ES5v zV5!)ULKLWBL7v2G%T%7&bFO1+1aP*Il!n=*mIYC*L#V0|W@VJBoX z$ly?gL#*8?LjBD&n@5J7iX0OnT~56Sv|3Uvi3{6~kNO#0Na&+JWjs(osgQtwDkTx* zwL?nV}?YLuy6Jn-Z!0mR?4wj zxn4>6gp&S=C{G}{Vf$p%NWf7-Me30pLvBF?^hpj-QbdsgcYx|8E(L3&jMAFo8e2SA zNgqhglH`sr6>@M&NrK^*l#`tFV~=xnDYWC}kpJMkEz(VL+f#B55LnCnZi@#?O^4wE zI7xP>j@05%To+RR0{fX9HmT^4ze=qjX;f|s_>oC74eHsrFJxBb%-TFyu7=XDhyo{S z4xkNFaYr+?9WscaM->q*lR5`VU8t>-Lc;*uP0J$9MDD{0%anGT2TRUA;{IAGX-#sC zsRQwu^YdW8Qg=m0)*?|x;+@MTV24k@<$7fLsd=DYz$Qt`aiD=x z_y)_9G!KbO(!vx>@TIUsy_k#}Wkl4=kh!GxF)k96w1s9vqaFIx4$#CxDwfD+bHm69 z(zr>>(I{YX3Jo^Pp>P-V9wcU^_J>54M;^Lpv~3_dR07t7V&1t|ng-}vd=ThZI z!3$1RWZ0yfP#CPFx3~?|JW2zhNJ|ouMe}I$V68w1vQlP5;*BI&zzLDXlNRNrVJB zfy>A#lHQ}lC#DVs3|#!XijJ_zgQabfG^gw&rjbgYaJU}^2{Wo#NVaBHDy0LI6mTT* z(i&BL0o50v2uAe5T|5Kh2oOLs8(6Z_9!HvY(Axmd`G zsE_)jYak{=rOcux+jCK0AWto*^-!#!r@O?|jTpM3P1c}DLL?E%1X3}GANnHZ3Pluc zNVxoHHNxA2LLuSe4t)WF`w;axj$~pCVfsT-GU2BP(+IAh3&T9K;~ZusT%`y;d`1Ea z%|{7Zqp@1@r91Qm$esOB4U^xBB-SB z;3)}9tO&Mu>I*SAkARV2PQmWNQWeKNRmKbgi$7GEHWXYTPZ0Nge*<+b|eM(a@Tj@Xt}6&777R= zf7#X-fB;J>3EB&!ANa9k$jA-|ah22$#6mbMF4_~qI!15+IZKi^2_#TclpowMZszPd z^aUVZB0NAE1OhNgBrH6&}G#6vrRHCZI1y7UF0fpY&z zUw}LB5C$*YQplk2+o8L1Ti`GPp`>0R7?Ib44h#=4g|S1!&C9D6;^U&e0DMkC4w@H9 zR<55DL5&zPmnZy7IP6BDCIOQS#vlD(gmNt>Lv#VPV(5isXMqjX))(Mej%J5YwBXah zRWhM@k$EN2Cjt}jfzenXjYP^yL|N%lq>(eyJ<|9oJ}uHUP>(zH1yZQ!I;z9g+mW95^@;KNVuxP zK!u)5x(d1=8CoGgNgkNQCKu*DV_PQhfp1Pwv5lBrENbz;vx zD2QCNYbO+050nxR10Xd@YlP)ax)NTN+^Hffy;NU-Oj=4l7YZVrO!^Gmbcn4XL{bR1 z23&Zg3y=|zE3nBcyCftzJ+u*ofJ{@CJ4D8~OJ9K2kO^0rz74+~QZ|hPt~ku>*phY# z)t6pL`{HV0JjyMHtVZS!)sBQAsThKm=nGgv4TV}tw}aBj00S?UOfVV1!1H_|B2tME zl9S`R8T668LMNv6fXbz_pCEkbcjp#j3<eE>rwSL8oNSEM-{(z&S&beyQvBp_h` zPdrRagSI5)36SQ)BEj3EF94sCW-2M~ETfCTaYkd9!T?4w3K7u#g}qCfo6Nn)Ot?N- zD)@{HQ+#K~sct7}dELDg)q- zBE(>%^ib%v%{gePz5uy(htdRQM3>|&TtOJt^az?a6#&rRL*#D)=2Ux3jcIr|iGBB;eNDQe_8@_^+Zh&k!Lnu=Cs2w5TnM!bb(+!&4MYOfn3@Nsvg3}MLvlRhTshRbST4=3s4k77we-h zfL1$WDK!I7UKyHb<_N&R&lj}{Dn^8t?$G|>2lFR}O&S(sF&Zb5XGMFY)UfEIFJJ{p zz|w>T6Fgc@fcA+*f1c3q(1)OC1!6k$DrDoFFGMQ>iHV{x0%dTN`Q2jl>Z>mRAC|HK zZiUDR!6f$RbciF+_fVD>(f@$cXS#QoS?O_LLI0*@hy)SCdBK?Ot1rN66Uhb}4R9OH zbVM;yZj=X@DgVwG2Iuar`Z|5ck|hvDP-ZDP(N|x95tlg?tq=w((d$oH zcTqJ$IZiAZO^o7Fbi+{0@P#}&%Js|^1}Bd41@lfhi}lkNKuw5&hw(obVG-slD#&Q3 zG+b6gFy4_1q6`K}44B;kSlcX|kg=k{aVAoQCC!4lxL03*A`4k9z$%73%s9qq48mwy zq3;BzT2$xhDlB@@1Au@HOC1RS{>daq^T%pIj@(~gfbN@_tP@1rCXDeyFt}=px0qg7 z$s!iO+{nSW02Jt@SV@+y@T5@;5^<_>E74b9fDsH$9!T=6wpeSYHr#n;dhsaWwlbCE zDGCjUk1#*6*^q&lbrj1umOzZn(9b#PW%LEuNJOl_&^ka{1sXlm3S4eV;aDHC>yo*Q zQ7on1Bh8NJyMcN;a}U^iNWqxJfU%6e00JvM7sq@?CuH9vLj0nJk2n-75l&QOTtpxS zohmAmkO#0tw1zI4#~Gunob_`00#s135@nJ_3Q5c+m@M&RU>-rffT52@i2?)I!cEVk zm~}kc4%T@LUyK`!ta5Dbbw7OpCL8Gc2<%I3AjqQYM6p~RiDn9m46cfWACoulU^ZYy zPVF0VORR+gDOE{|4a0K!0$Gf#4O((E2BvqkD44HVrSf&sOxTvOEMn?rZN|K9WicB2 zdb=Py#}Yiis80zZCk34+XnP!(iowU>rRl=PVF1 z;_@7pw*@H6=?hT$$1cgFBd`)+lfW=dX_Q!rVE)07L=6<$0s$S9E`z=rYkL_PQSsoK zQRtC_FQ+enx(R)Ru7|>cc$=WI!hVv?pKaSO&6KVu%JTYWza<>!_fV zgDrwLC)<-J3#5H2T=ohx~!{MZ}!-HZQ=2je|;B^ zQ1t~maB4n18%@{Jsm9!*^KU=aaTkoOgXFb^dSePA`|KJ%toi~i7_nnkeSx9T!#6KS zi>=L`ovS5TeadV-yY52ey5Z5AZytQdjRiHf^?K{~jxKeN)~kJp)~YXX=+wkyr9+oM z^#!UERfzj1PaQp2wvMsNp_7$*&j?sh_nVAuA(r>N;eppyPgGk97O$!pU2hmW_x;1e zdr_yku%|;$p(zW{Pep-~=sG}CB0%*8I&MLq4laZ2Vkf`I8cN=%`T}`f^##)D>~txo zQh62>AnZ*;qxT+IZmLV;v)MwuHaE*;xA=VX6NAH}2M!FLD}^8y|FHOMQ`d~Q_tQG;)7f^ix)fZ5G0o4~!eE~^2iUd0;Qc5{69uNx( zR6$UeppuXp2PzRws+(|-X_mE>LgN_5Q_v(H0;CfWJwX;7@e9=#P<;V%$f_@(`U00n zUx20UzD@ktE^FLYT;R<^AO7g^Klsj8i5hGkc~hY;z%P9$0*Zhlpa>`eihv@Z2q*%I zfFhs>C<2PWY7wxv+m5Z(e%}X$&Woc1W~**p;;i3D{qCrfOjO(O*mwbj-V97 zRzhw$$8a3VitsNWsKG0@nwIPkvPvmyNQiL1;kUxnfb}NCOJTF%nry&xPT)=pjT>GN zR8TQ;g)YZ8!4ZP_CL+fL9SU1ms5E7W6{>oSCnu~U2l7@H5^+VDIfQKi(E^Sg3_HFP z$`4fDG=ypoLleQ?f63|CILr?`*CF_z#DMc9Vh6bmB|L**hkZTl zcN-EL+6}_nLdqKnU|eT~#{xaDxZ+NI0Sjgjd|3FNLZr#~4{k2P-J;!`JD`EEH~Q3Y zBcYw+9}5G|3FIzAEf(YYf_2lSFAzif!&MVv3#3Ar+|Z=pqClpCwTlTm^gYBGU?agd z;wWH((tsFaqa~h+40q9H@chJ2zG*yaU`&rWIkw;tELF%-aG#;fNdtq~ig7by5WtMI z1Ts$At1t-6W3`@34$ENVyNmiG)0<#SIBB-FxGt7 z{qT4o@F-l}$vmo1vw31+%ro6!Loa%gkHDa909)b;6b zaw$c#T%s?4L_GvdBS#+~3d00_4=B!1%OK6;=L|_y?k^XNra1g{2M=U;$nZkpp+m!l zktXN4R9^t!c9?$XXIuOQ1|27W$vPU|Q1pf|J`+41B1gzG$^03t%uHBV^9#7}KlJi9vRP^A=@) zM%>cdxkq0Bn|DqKWe(nhl{#t)G;umMrsxdjVirw{$2Ng(0q+uF0Gm67@&calke{PM zkOjT$ag?iF_j^LA*%Fw2F3DURqi8tx``@Il52qPT%-2C6-! zWIGBtXj0&i&E+-+QF!5uSWK=i14Jgi&w!M|p=A6qP>}Z^(lTxFk~pA?ib)Lf8UC{k zg`WwifB<+91QOzFYd%7SBo&p7)a19YwSe=LOreS6={XEan1qY8a&4RcNoSSf8U@K<_tQq$;>EEHuCsfeGG>5(|>_G2H$D zl5T0bfNlu-_J~SAgl^97G1??3v(OJB{G*}IgBfn+4A@4VGcX{sU?E##!LVdjV6=q3 zDQs&YQo_(JNzQ%r1yI09KX2@SYI$bW43^$1#2PJJNT=k)lJ2VUnN;~o#HeLZ( zP8YqQuf70HF;7yOr%9jS+>mmB?9Ywl3b;=!K)?aUJp^UE($O5iP=fhEh}%dVEUPbI zL%Me095a9f2|_;x&H||if0{K2QX$MttW*%qV6b4$6Rjz1$Z3j5QPJ|6tBL;l0x4u! zdNGqM`YHit+XzL;M1y`(j7kg*Ek!qjdkGx)ViaZsDGYg}WUwhHSEH@m$MZmzzQ-qdM0Aek|TxkYnkL#;1fUgI` z1C5?V5|ba(j)}Sst(H|PqCG5M@GhqZ3KmJqx{1jF2?Y^T$(Za*GIKwD0b80cOFl$~ zklaCUC9!}u;&8M+birI&F`VlNYY0g92`f6rdns99vZKqCRQ)O#%W?o~eLf(|{4YtmRFUK}E(NMp#n@3x<6rS3X?cLd@Ek z`Q!lHJ~WtU#_0GU_k_0vjV(byi+u)N4G|z2kum<@9^yiRy$|DmF%B=IFF>zn)8LAT z0!uv9w^$xP&PUCIp$L`|As^M70^@aach} zffWR64@Optbre_41Q^mFxu+q^ciKJvJgmQwvVr3bpByfGxngu!`ym2@_6V$YH)!s& zY}9j@A?PrPG?L0Ybu^{89%IA;+sV0T92 zDeok!P*(k*L1PuaAQ>I$lx(8tC=MG;z6mWG6i?6sU@0P+UI-U)YzB3jVbMs~E3w>i z4ZZ=Gkf>_yeRql%d9%BQlB9!2mKD(`$0av60 z%0<93`T~rWmc;^xg*Ckboff%m5D?G|@OqCP4XawyH>^u=8WBP@MU) z`U1>?2rr1b4pMk@dzP*YDLg@1hSjZz*m3UYPH@M$1T=$?@+QUQ;4RUgV*3nNhBG98 zLj;X*1^fqoSgs@oMHiN=EFs&7q!`MqnO(-a+WxOZ5fFV1Go;^18{mRU93=(z@fJB63ozVIo zO8S@6Cm28+!=oV*eIk%S2u9Sb5n(Js7H$7m0zZW25l|# z0w4hq7|CH}D8#Z+Cm$FPn9M-Q1y9${Ecoy|RAP=qeaMCYRaE3x)a9Dq?KCZ|Fa4=VstgvhMoW(#IqEA%Cz zD3X?AwY!u5L__eVR=vP`XI}lvf17{hXC*E$xZ`63{9hl6fFhs>C<2OrBA^H;0*b&@ zg}_ITY~FeM{adzd+4lKe+iu*px&qxd`=|e?_1}}Xn&qmT9A|)iM?$?31vuD{pv7*J zXtdceQOip05EZ7BxUtvCsBL8D5m;2@VFQJ~3NrAr$S=zmsOdrOpTsozM=CdQMiBQb zEOwB&C#8p_iD;zbFkrIHVi%0V10`;hr{xsYkPIz~44Oq^NVsDOLPDI(E2T9tHWido zP*9gpQNrGX$~#i`B6Ch5mmjmAVVRHl0B0r1=_0Y)+_1S=bg&`Hs3ekdKhA;55lMw& z*ui0_$DvdVKNqrz0b=g#`LO;f)ZMAP3d6FBtC_YXze#DIBx@<4qlSh(69rt9P-3=1 z#S8UU)NqpSwH*qD#cqRCsOZ`YTD-Xcs}&arq$ZeZ2JmLzPcerS`m!VCxKuAvfMBr) z%E^kmB(2e8@Tk(G(hfM-x>3m|z*AxMvdXPgU{QW(NF@~>B5a=6Y>J7EPrVVvQ`n;5 zZNM%R-FFJ+s66&%3rj&;oKi}J&qz@kK%H#4!zD>B*?Lm@>^-p{qngUZ>4<+&0!o2Q zN|_6XMEu`jlSKg#CLNeQaD@r}J*AHU_ABMVs16QOoIxnkVuww2kksW#kc2dJLRp<7 zUQCpjNmUiK0o*+Hms})uT^2<(1^RgRl$S{j3^h|JsbnfbB1(<9TuNUk;br?rr4UXE zVGeGTPqNqK4^&Ru)WzXS#9p|BjEe7O8>7|b)Cf_77;`~vgDHr`9g4$adm%e-sr+&% z)?nvOMFPEpRzbBcS#EJ>DQ*C^7R}u)yJXZK@+q&S0eXQ~q^R?8{ZD=w7ky=H}6hRpz#23BYkwlbJX6!#rtIBKmZ@eGK1a5t$G zaVgZ{_;M$yMP(~ZX-`s|TRSd*y9w4>Y^K>{ClPhn+>V^PDqDNn7}cfhn^F1@n-tuU zC@a7j4SNynBj_CCw2;u>ml*-|BWTNnE{ceXhORu+kD$b%_^Eyb zlZTGpSGl`-&r+%JE%2F1JKxuM}|gUzIQqGU@;PyGUey)b9e3@ z9^Jcl@cf}dAYWdS|8{lB1j{QO0>Q0YP`o@@eZ!&>aBml%nayVAW@||)DSYfW?>#j! zG2VO1r;Z)D|5UYdP!2j?X?<+0_tMU7-!(isIy!jnbaBSBbM?kT@ndJ#ITtVWIqAmZ z^V!mqXwb?X#Y^n)W6Y;I*7d8)%?%Ox` zbSZOR{68p)%$ehr{$pbE_4X&*gXMUo{mC?2 zsGW@($p25xM~#P@$5TIo=IWyDN6@7^c;hR-_=WpFX8o%Cb<4=@qC0r|$a__J@G@_B z^>B)SBA^H;0*Zhlpa>`eihv@Z2q*&U34x{-plcZDB%!EBS-<10tRc8%edrDfkDy&I z@Ts?a^EW^61829D`T~>i2)0hHr(31xst70oion%~z(yMD3t zpVqu;&8ybDYR#+GyzW;Xf$|8HN1!|cY~Rk3jth)Q>>@2-J@N-a6)1c2hM#{Rq^LU?cev zbmB1Tk;9hc`GZ}bk^!;#N%=rYOfNoB?|EMb_r5sIo;O^a zfPe1$hlls>-#>U^PsjB(uc+TuuI>Vx2fB>cxXQ~@eS!N{B(fJ1Pix4i%w*GzHfg?B zH%6M~qIL`tBaMD~ICpFr8og`Z3bGEWFVIpFP<;VuR`NKTE!5FjkY#my7HsPaY?GZ_ zt6tz6xBT_QzkB#^e`+hQZ5sKS{ORVAZQksbh3ZqNK85O2 zs6K`2Q>Z?Lac*PLfN{KmjYX6rVDIBikclS*9v6OKny!a4UhMhU(3`FunMUZOfo<4v zSP1L4{0J=DH4^;()TdB=3ZJo0A#2*3w(w)$CVq734u0R8=f6_>Z=ZX+L=Cp?{z_AK zaQ9bKc@RMKp$I4fihv@Z2q*%IfFhs>C<2OrBC!4uD6{~>nQ2@K&mMl_p?jxS)(_me zv2_PuC^xlLFR=UIO&8w3=hOdMUW+}v8f^?tG1ZJB3%AI^0@bul!*7~r=Bbr>xO93r zZ@QUj8?j;8wq>|kU_|&rVgza>N#dBk8N>-@oN<6-WrU3>){|*m7*tvwk+P9&@&N zdBH~Ln{LZ~G&N#8B#k7+Vl(G333jHKQ`#mRJ}hlr8|&K`7fUOOG0#l{j8cu%0uCEr z)VR1KjtS$@Xxg@>89uCmDJGVIA30&d=kpvRRWZ>FXBn`{V zIG`Jr&YY%)Q7*2)rR6HIP8SzY3`;S8HL?WH(3tZGOdk(cH^YQCar_iVSzK;$4JB@h z{ill^Xp{nTl*%dEw(B}h%l9_%4Z}>aZVhQyu?LWq4M(&(T2D&* z3rzly$HZZ=kfkvdcApsba@j2-SC9Me;vZ^7yu;6|#I_A1u{r2nW0_ocvoVHv$buYR$5`BQ{hUZj$8s#sbZ9AlZr~a#cE^F? z;1iqLu9(JVX(Ud)R$MejOi~>i|Kt{uPHy0&n@ejjyvW8Y*Rw#vqsiGhhQGF%nRpkw zJ{>QkOGUWiVv{P3KOk0J^cVbleS-^aI`vu+zQ3`L32YG2$#NPie#nteGs4#x(^nHW zSK2Qv-62*TWta^!I>a>6AhViXhH=9&=~K-cp4*1Uv3w8P{Upiw zB6?h$;Nnd9-1czHjm5(^iek^h>{(8eK>{;e8hsN@+E4D;E^o&d7%x^DaUKT|Eh_Ls z+BLSWxsf~EZoGCaz8tUTFbZr8X5~%=a!edyN#Nng+=MeXQp}qj10!5Z`b_M^*t~|B z+;i@&YsYj3Jfyh-JIDez4;dqI{55#Wm|@5<13ZWE%*ATC#FN&5r*st3w{uRCyOGBs zF4%4mThb2Q2$mu(HsNYLn=WlRQ5@t+Zrd1I+hs%YO);3pO}p7Tv5Cg|)}Y%ZBpPT; zcZcf%xi`ECY1e6I7*h!|9GUk%KYg5yb@dblcM@lAHfXFTv+Ms+SGHT?t!Yn-N2dMHNe z%?=gPW=zjw7;UyykDeel+&CxWZA?qD8K!cYcVK6R&8Wcm&paF0^nC_sIv?)nE}g?9 zzr+|*_Ng#r!p0XHXY8`Ma+SYBM$A~0)7}h==@+YO(r6(+ zT~cAp_FPgDXGPEQxq0|A15QAlz2!?o?f|~d*uw|-F-Hkgpzj&L z%Mi|5z==VKcQV(M`ayIRiz_r7$7#+`*nT@n;H4hTmo{YK|LjG1z^K7m$i`$E%$e-O zu^S{w>(Pgpsu?PAu}&DU<#YvXnHCxF89quF=&u3qV)Dhk+F*{em@aaY5yE7OXCRk$ zg6BG&H^RW04u;b?BG)*!V{Fev!f|~=25ioirqG(LScS!ON!+M&h9;W1lc$O0GHc*f zP6PElT*etRXn+~RCJl?RI18OTHZe^vXH?UnY11cYF?_IWyjFnmHICz9W|}c4K>M^9 z=@R-K)0)L1faw;icH7DM!pw~w(={0YO^n@hnp-h?(KZ>nxd${oukGQ^N{UkoY{vk} z4Bv2!!ez?K@Gj5r=MFf1rhA8(l^zEc^lw@Q;3f>`g}c1#$@neff<^0ZHegnpi6!Rr zft}_Kw&f`|$^*=le`gGY^pwb_w=%nOQ&|#7AF^Z#=%|8vjt80P>urk&DoSqoXei&N`{fD_A%$Z(T$(kX)^^CVHxpKoxxl$6}*u}FF zW=LffUv4D=!<4~-@tVaW14;=8-GxavV)4#u70?~#KH3tl@!VFXl3d)~U1la001RFX z#H^!O#<2uqY-WIz-sdr#a5oIO5rE$ujM+#;bjZZFE#@gkgn((1_RUIz0~d1{qgYD2 z&)w9?z=(;)#wVVx#`P8h23^`^%y3wVcjyb)IrAbH$9zU7WZ&Z^EZl96jRIQ%gALxmY;S=c+f({@+PjNWwSbzfu z9AFo_n4KN6y1J^q_nR5&BWjlXmz0O9-o*otQqA#XhdEFAQa4oAGxhWnuQ$+qrC`^o zAdhHk-)rBRg6Z1!rsc)DQv+o)!3{z@8vHbGf1w+g$m_7o$6*#)#)W7(1p|FFPL4UA zK+RP|uK!)9&8Qv8puJBu>({1KPZw!ImKlq*ZnK%b-S2f%?dT_!$me;rd~L?;01eKb z!G1JCULDZpP^HZyR?`-9qbN6xbxU0>mVOy^QEO=O%7^-Geu++@LYTzzxoaG7R;8`8 z5~7aj^yiTe&VfqmYy^`4k)TYsuEa}y_C>Ohn_Uub8`G8{bnCm(9De!w|6a#z4c85-nKkHmzATDLIzY z9P1H%Gy@w`x;%U0dUT^9*od)fwR5w2%ht?8W_cyb_RbpN9HM@8hxucRo+&xZw0SOX zK7TBq$p84PfY(nhaJ>8R{lk~{-{N`%zy0x#>k;_jzTFYHBXCFHj=&v(I|6qE?g-ow z_#Z{!@4tS3_4eQEy!@K!=lU*RpI3`1{?hcNd;u3=xHzy4Ob-*rSr-sA$gj)yaiGYJW$|; zbb^(Ot+_L$o;(2d3(IJqTbkp|Nyq>;lIi5F1Pk5g0HCM_&h0ej26Eupp(ZYQjHdvb zcyd14LAa1a;3hfW5O&h(K$Kulq64JpcUY8QLc|EInF@Nq>I*#pH3J#HO{|g$0+`8; z6-c7bR;J(t-MdXVdSTMwF$+hAY{L_gkB2w$|;zd3UtD0@iU z(7{|B2hKInsF^2+V-0xzblPqgD+_lFs{ukvS-E_G<3P(FEQf;(rO~K&6qZDqK;Jc4 z0ld?x49>V<6rt0fd@STw$Zr%33Z};PnfxEv7=b*PC9r4>bnkp2QuuK=ti$_JI4>GkqH0%ya7V|b*> zkmIRIHu`)-HdmhopY@- 4096 workaround) | `test_messages_litellm_dispatch.py` | +| Aggregator: text deltas → single message, tool_use `input_json_delta` concatenation, SSE byte chunk parsing | `test_messages_litellm_dispatch.py` | +| Cost accumulation across multiple message events (uses `+=` not `max()`) | `test_messages_dispatch_cost_accumulation.py` | +| Token/cost extraction, cache tokens, model name extraction/override, SSE encoding, malformed/negative cost clamping | `test_messages_dispatch_cost_accumulation.py` | + +--- + +## 🏷️ Provider Field Injection + +| What's tested | Key files | +|---|---| +| Direct upstreams get bare `provider_type`, OpenRouter gets `openrouter:UpstreamProvider`, unknown/missing becomes `"unknown"` | `test_provider_field_injection.py` | +| Idempotency: double-stamping never nests prefix (`openrouter:openrouter:Google` → `openrouter:Google`) | `test_provider_field_injection.py` | +| Whitespace stripping, non-string/non-dict inputs skipped, `inject_cost_metadata` also stamps provider | `test_provider_field_injection.py` | + +--- + +## ⚙️ Upstream Providers + +| What's tested | Key files | +|---|---| +| Azure: `api-key` header instead of `Authorization`, BOM-stripped API version, deployment ID path construction, base URL stripping | `test_upstream_azure.py` | +| Gemini messages: `inject_thought_signatures` for tool calls, `_openai_chunks_to_anthropic_events` translator (text, tool_use, [DONE] sentinel, blank lines) | `test_upstream_gemini.py` | +| Routstr upstream: balance RPC (auth header omitted when api_key empty, connect timeout → None), `/v1` path preservation, native messages support | `test_upstream_routstr.py` | +| Error normalization: HTML/plaintext upstream errors → JSON envelope, JSON errors pass through unchanged, empty body handling | `test_upstream_error_response.py` | +| Litellm provider prefix detection: 40+ providers from URL patterns (Fireworks, Groq, xAI, DeepSeek, Together, Perplexity, Mistral, etc.) | `test_litellm_routing.py` | +| Azure ordering beats `api.openai.com`, Ollama localhost detection, casing/trailing slash normalization, custom defaults | `test_litellm_routing.py` | +| Subclass prefix override wins over URL detection, native messages support flags | `test_messages_litellm_dispatch.py` | + +--- + +## 💵 Cost Calculation & Caching + +| What's tested | Key files | +|---|---| +| OpenAI vs Anthropic cache token formats (subtractive vs additive), cache_read exceeds prompt_tokens, malformed/boolean/float token coercion | `test_cost_calculation_caching.py` | +| Token field fallback order, missing/null usage blocks, both cache_read and cache_creation simultaneously | `test_cost_calculation_caching.py` | +| x-cashu cost injection in non-streaming/streaming responses, `cost_sats` rounding, existing usage fields preserved | `test_x_cashu_cost_sats.py` | + +--- + +## 🧮 Token Counting + +| What's tested | Key files | +|---|---| +| Local `count_tokens` shim: simple messages, litellm fallback, missing model object, empty body, malformed JSON, system prompts, Anthropic system block list, `forwarded_model_id` | `test_count_tokens_local.py` | +| Image token estimation: low/high/auto detail, small/large images, base64, multiple images, `input_image` type, no images | `test_image_tokens.py` | +| Invalid image data falls back to 512×512 defaults | `test_image_tokens.py` | + +--- + +## 🧠 Model Prioritization Algorithm + +| What's tested | Key files | +|---|---| +| Cost scores (basic, with request fee, expensive models), provider penalties (regular=1.0, OpenRouter=1.001) | `test_algorithm.py` | +| Model overrides for missing cached models, deduplication by provider identity (not provider type) | `test_algorithm.py` | + +--- + +## 🔄 Reactive Request Correction + +| What's tested | Key files | +|---|---| +| Stripping deprecated `temperature`, unsupported params from request body before retry | `test_request_correction.py` | +| No correction when param absent, label already applied, error message doesn't match, empty inputs, non-object body | `test_request_correction.py` | +| Deprecated model name NOT stripped as param, streaming 400 buffered error is correctable | `test_request_correction.py` | +| Sequential two-param correction (with `applied` set guard), immutability of input | `test_request_correction.py` | + +--- + +## 🗄️ Database Consistency + +| What's tested | Key files | +|---|---| +| Transaction atomicity: balance update rollback on failure, top-up rollback on network error | `test_database_consistency.py` | +| Concurrent balance updates via direct DB operations, race condition prevention | `test_database_consistency.py` | +| Primary key uniqueness enforced, numeric field constraints | `test_database_consistency.py` | +| Connection pooling under load (50 concurrent requests), index usage (primary key lookup < 10ms) | `test_database_consistency.py` | + +--- + +## 🔍 Nostr Discovery & Analytics + +| What's tested | Key files | +|---|---| +| Provider discovery endpoint: default format, `include_json=true`, data structure validation, NIP-91-only parsing | `test_provider_management.py` | +| No-providers, offline providers, duplicate URLs, Nostr relay failures, malformed URLs, parameter validation | `test_provider_management.py` | +| Admin routstr top-up with transient upstream failure retry | `test_provider_management.py` | +| Analytics snapshot payload: top model usage aggregation, schema/shape, fingerprint ignores `generated_at` | `test_nostr_analytics.py` | +| Analytics disable/empty-nsec skip, deduplication of unchanged payloads | `test_nostr_analytics.py` | + +--- + +## ⚙️ Infrastructure & Settings + +| What's tested | Key files | +|---|---| +| Settings seed from env, DB precedence over env, unknown key discarding, payout settings defaults and validation | `test_settings.py` | +| Periodic upstream models refresh loop: picks up providers added after startup, disabled at non-positive interval | `test_models_refresh_loop.py` | +| Logging SecurityFilter: Bearer tokens, Cashu tokens, nsec keys, API keys — redacted; case insensitivity, multiple secrets, non-sensitive messages left intact | `test_logging_securityfilter.py` | + +--- + +## 🧪 Integration Test Infrastructure + +The `conftest.py` (~400 lines) provides: + +- **`TestmintWallet`**: Simulated cashu mint with fallback token creation, secure token uniqueness via `secrets.token_hex` to prevent hash collisions in concurrent tests +- **`DatabaseSnapshot`**: Before/after diffing of API key state (added/modified/removed with field-level deltas) +- **App fixture**: Full FastAPI app with all wallet/proxy mocks patched in (credit_balance, send_token, recieve_token, etc.) +- **Authenticated client**: Client with persistent API key and 10k sat balance created automatically +- **WebSocket mock**: Nostr discovery patched to fail fast for performance +- **Docker vs mock mode**: Switches between real Docker services and in-memory mocks via `USE_LOCAL_SERVICES` env var diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 3f643a74..d0874866 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -331,14 +331,24 @@ async def _compute_ehbp_actual_cost( actual_model: str | None = usage_dict.pop("model", None) # type: ignore[arg-type] pricing_model_id = model_obj.id expected_upstream_model = model_obj.forwarded_model_id or model_obj.id - if actual_model and actual_model != expected_upstream_model: + # Case-insensitive comparison: ``get_model_instance`` lowercases lookup + # keys, so a casing difference between the header and the configured + # ``forwarded_model_id`` (e.g. ``GLM-5-2`` vs ``glm-5-2``) should not + # be treated as a real mismatch. + if ( + actual_model + and actual_model.lower() != expected_upstream_model.lower() + ): from ..proxy import get_model_instance # ``forwarded_model_id`` values are registered as routable aliases in # the global model map, so ``get_model_instance`` will find a model - # whose upstream ID matches the actually-served model. + # whose upstream ID matches the actually-served model. It also strips + # date-version suffixes (e.g. ``glm-5-2-20260415`` -> ``glm-5-2``), + # so a resolved model that is actually the *same* as the requested + # one is treated as a non-mismatch. actual_model_obj = get_model_instance(actual_model) - if actual_model_obj: + if actual_model_obj and actual_model_obj.id != model_obj.id: logger.info( "EHBP served model differs from requested, using actual " "model for pricing", @@ -350,16 +360,21 @@ async def _compute_ehbp_actual_cost( ) pricing_model_id = actual_model_obj.id else: - logger.warning( - "EHBP served model not found in registry, falling back to " - "requested model for pricing", - extra={ - "requested_model": model_obj.id, - "expected_upstream_model": expected_upstream_model, - "actual_model": actual_model, - }, - ) - actual_model = None # do not propagate unknown model + # Either the served model is not in the registry (unknown), or + # it resolves back to the requested model (e.g. a date-versioned + # alias like ``glm-5-2-20260415``). In both cases use the + # requested model's pricing and do not propagate actual_model. + if actual_model_obj is None: + logger.warning( + "EHBP served model not found in registry, falling back " + "to requested model for pricing", + extra={ + "requested_model": model_obj.id, + "expected_upstream_model": expected_upstream_model, + "actual_model": actual_model, + }, + ) + actual_model = None # do not propagate unknown / same model else: # Models match or no model in header — use requested model's pricing. actual_model = None diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 3548b4f3..a5579504 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -483,6 +483,86 @@ class TestComputeEhbpActualCost: call_args = mock_calc.call_args assert call_args[0][0]["model"] == "llama3-3-70b" + @pytest.mark.asyncio + async def test_case_insensitive_model_match(self) -> None: + """Casing differences between the header and forwarded_model_id + should not trigger a spurious mismatch.""" + model_obj = MagicMock() + model_obj.id = "tinfoil-glm-5-2" + model_obj.forwarded_model_id = "glm-5-2" # lowercase + with patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=5, + output_msats=10, + total_msats=15, + total_usd=0.0, + input_tokens=42, + output_tokens=10, + ) + # Header returns uppercase — same model, different casing + result = await _compute_ehbp_actual_cost( + "prompt=42,completion=10,total=52,model=GLM-5-2", + model_obj, + 100_000, + ) + assert "actual_model" not in result + # No mismatch: requested model pricing used + call_args = mock_calc.call_args + assert call_args[0][0]["model"] == "tinfoil-glm-5-2" + # get_model_instance must not be consulted for a casing-only diff + assert not any( + call[0] == ("GLM-5-2",) + for call in mock_calc.call_args_list + ) + + @pytest.mark.asyncio + async def test_date_versioned_alias_resolves_to_requested(self) -> None: + """When the served model is a date-versioned alias that resolves back + to the requested model, no mismatch is propagated.""" + model_obj = MagicMock() + model_obj.id = "tinfoil-glm-5-2" + model_obj.forwarded_model_id = "glm-5-2" + + # get_model_instance strips the date suffix and returns the SAME model + actual_model_obj = MagicMock() + actual_model_obj.id = "tinfoil-glm-5-2" # identical to requested + actual_model_obj.forwarded_model_id = "glm-5-2" + + with patch( + "routstr.proxy.get_model_instance", + return_value=actual_model_obj, + ), patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=5, + output_msats=10, + total_msats=15, + total_usd=0.0, + input_tokens=42, + output_tokens=10, + ) + # Tinfoil returns a date-versioned ID + result = await _compute_ehbp_actual_cost( + "prompt=42,completion=10,total=52,model=glm-5-2-20260415", + model_obj, + 100_000, + ) + # No mismatch — resolves to the same model + assert "actual_model" not in result + call_args = mock_calc.call_args + assert call_args[0][0]["model"] == "tinfoil-glm-5-2" + # --------------------------------------------------------------------------- # TinfoilUpstreamProvider From b30346bcb6839428bd22d5b0d08a03edfcdb057b Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:17:22 +0530 Subject: [PATCH 24/27] chore: untrack stray local files & gitignore Remove accidentally-committed local-only files: - .wallet/wallet.sqlite3-shm / .wal (runtime DB sidecar files) - AGENTS.md, TEST_SUITE_OVERVIEW.md (local agent context artifacts) Add .wallet/, AGENTS.md, TEST_SUITE_OVERVIEW.md to .gitignore. --- .gitignore | 3 + .wallet/wallet.sqlite3-shm | Bin 32768 -> 0 bytes .wallet/wallet.sqlite3-wal | Bin 428512 -> 0 bytes AGENTS.md | 87 ------------------ TEST_SUITE_OVERVIEW.md | 180 ------------------------------------- 5 files changed, 3 insertions(+), 267 deletions(-) delete mode 100644 .wallet/wallet.sqlite3-shm delete mode 100644 .wallet/wallet.sqlite3-wal delete mode 100644 AGENTS.md delete mode 100644 TEST_SUITE_OVERVIEW.md diff --git a/.gitignore b/.gitignore index 13985d7c..0d6c7236 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ dist/ *.db-shm *.db-wal .*wallet.sqlite3 +.wallet/ +AGENTS.md +TEST_SUITE_OVERVIEW.md *models.json .cashu .relay diff --git a/.wallet/wallet.sqlite3-shm b/.wallet/wallet.sqlite3-shm deleted file mode 100644 index 43eb655de1b564fe464db10a5f0c4a5dcaa9c1f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 4096 workaround) | `test_messages_litellm_dispatch.py` | -| Aggregator: text deltas → single message, tool_use `input_json_delta` concatenation, SSE byte chunk parsing | `test_messages_litellm_dispatch.py` | -| Cost accumulation across multiple message events (uses `+=` not `max()`) | `test_messages_dispatch_cost_accumulation.py` | -| Token/cost extraction, cache tokens, model name extraction/override, SSE encoding, malformed/negative cost clamping | `test_messages_dispatch_cost_accumulation.py` | - ---- - -## 🏷️ Provider Field Injection - -| What's tested | Key files | -|---|---| -| Direct upstreams get bare `provider_type`, OpenRouter gets `openrouter:UpstreamProvider`, unknown/missing becomes `"unknown"` | `test_provider_field_injection.py` | -| Idempotency: double-stamping never nests prefix (`openrouter:openrouter:Google` → `openrouter:Google`) | `test_provider_field_injection.py` | -| Whitespace stripping, non-string/non-dict inputs skipped, `inject_cost_metadata` also stamps provider | `test_provider_field_injection.py` | - ---- - -## ⚙️ Upstream Providers - -| What's tested | Key files | -|---|---| -| Azure: `api-key` header instead of `Authorization`, BOM-stripped API version, deployment ID path construction, base URL stripping | `test_upstream_azure.py` | -| Gemini messages: `inject_thought_signatures` for tool calls, `_openai_chunks_to_anthropic_events` translator (text, tool_use, [DONE] sentinel, blank lines) | `test_upstream_gemini.py` | -| Routstr upstream: balance RPC (auth header omitted when api_key empty, connect timeout → None), `/v1` path preservation, native messages support | `test_upstream_routstr.py` | -| Error normalization: HTML/plaintext upstream errors → JSON envelope, JSON errors pass through unchanged, empty body handling | `test_upstream_error_response.py` | -| Litellm provider prefix detection: 40+ providers from URL patterns (Fireworks, Groq, xAI, DeepSeek, Together, Perplexity, Mistral, etc.) | `test_litellm_routing.py` | -| Azure ordering beats `api.openai.com`, Ollama localhost detection, casing/trailing slash normalization, custom defaults | `test_litellm_routing.py` | -| Subclass prefix override wins over URL detection, native messages support flags | `test_messages_litellm_dispatch.py` | - ---- - -## 💵 Cost Calculation & Caching - -| What's tested | Key files | -|---|---| -| OpenAI vs Anthropic cache token formats (subtractive vs additive), cache_read exceeds prompt_tokens, malformed/boolean/float token coercion | `test_cost_calculation_caching.py` | -| Token field fallback order, missing/null usage blocks, both cache_read and cache_creation simultaneously | `test_cost_calculation_caching.py` | -| x-cashu cost injection in non-streaming/streaming responses, `cost_sats` rounding, existing usage fields preserved | `test_x_cashu_cost_sats.py` | - ---- - -## 🧮 Token Counting - -| What's tested | Key files | -|---|---| -| Local `count_tokens` shim: simple messages, litellm fallback, missing model object, empty body, malformed JSON, system prompts, Anthropic system block list, `forwarded_model_id` | `test_count_tokens_local.py` | -| Image token estimation: low/high/auto detail, small/large images, base64, multiple images, `input_image` type, no images | `test_image_tokens.py` | -| Invalid image data falls back to 512×512 defaults | `test_image_tokens.py` | - ---- - -## 🧠 Model Prioritization Algorithm - -| What's tested | Key files | -|---|---| -| Cost scores (basic, with request fee, expensive models), provider penalties (regular=1.0, OpenRouter=1.001) | `test_algorithm.py` | -| Model overrides for missing cached models, deduplication by provider identity (not provider type) | `test_algorithm.py` | - ---- - -## 🔄 Reactive Request Correction - -| What's tested | Key files | -|---|---| -| Stripping deprecated `temperature`, unsupported params from request body before retry | `test_request_correction.py` | -| No correction when param absent, label already applied, error message doesn't match, empty inputs, non-object body | `test_request_correction.py` | -| Deprecated model name NOT stripped as param, streaming 400 buffered error is correctable | `test_request_correction.py` | -| Sequential two-param correction (with `applied` set guard), immutability of input | `test_request_correction.py` | - ---- - -## 🗄️ Database Consistency - -| What's tested | Key files | -|---|---| -| Transaction atomicity: balance update rollback on failure, top-up rollback on network error | `test_database_consistency.py` | -| Concurrent balance updates via direct DB operations, race condition prevention | `test_database_consistency.py` | -| Primary key uniqueness enforced, numeric field constraints | `test_database_consistency.py` | -| Connection pooling under load (50 concurrent requests), index usage (primary key lookup < 10ms) | `test_database_consistency.py` | - ---- - -## 🔍 Nostr Discovery & Analytics - -| What's tested | Key files | -|---|---| -| Provider discovery endpoint: default format, `include_json=true`, data structure validation, NIP-91-only parsing | `test_provider_management.py` | -| No-providers, offline providers, duplicate URLs, Nostr relay failures, malformed URLs, parameter validation | `test_provider_management.py` | -| Admin routstr top-up with transient upstream failure retry | `test_provider_management.py` | -| Analytics snapshot payload: top model usage aggregation, schema/shape, fingerprint ignores `generated_at` | `test_nostr_analytics.py` | -| Analytics disable/empty-nsec skip, deduplication of unchanged payloads | `test_nostr_analytics.py` | - ---- - -## ⚙️ Infrastructure & Settings - -| What's tested | Key files | -|---|---| -| Settings seed from env, DB precedence over env, unknown key discarding, payout settings defaults and validation | `test_settings.py` | -| Periodic upstream models refresh loop: picks up providers added after startup, disabled at non-positive interval | `test_models_refresh_loop.py` | -| Logging SecurityFilter: Bearer tokens, Cashu tokens, nsec keys, API keys — redacted; case insensitivity, multiple secrets, non-sensitive messages left intact | `test_logging_securityfilter.py` | - ---- - -## 🧪 Integration Test Infrastructure - -The `conftest.py` (~400 lines) provides: - -- **`TestmintWallet`**: Simulated cashu mint with fallback token creation, secure token uniqueness via `secrets.token_hex` to prevent hash collisions in concurrent tests -- **`DatabaseSnapshot`**: Before/after diffing of API key state (added/modified/removed with field-level deltas) -- **App fixture**: Full FastAPI app with all wallet/proxy mocks patched in (credit_balance, send_token, recieve_token, etc.) -- **Authenticated client**: Client with persistent API key and 10k sat balance created automatically -- **WebSocket mock**: Nostr discovery patched to fail fast for performance -- **Docker vs mock mode**: Switches between real Docker services and in-memory mocks via `USE_LOCAL_SERVICES` env var From 02109616b92707ec14dadd0abc673ed132d9a6e4 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:16:21 +0800 Subject: [PATCH 25/27] fix(ehbp): compare resolved upstream model identities --- routstr/upstream/ehbp.py | 67 +++++++++------- tests/unit/test_tinfoil_integration.py | 107 +++++++++++++++++++++---- 2 files changed, 131 insertions(+), 43 deletions(-) diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index d0874866..ba58aae7 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -50,6 +50,14 @@ _TINFOIL_PROVIDER_TYPE = "tinfoil" _TINFOIL_ALLOWED_ENCLAVE_HOST_SUFFIX = ".tinfoil.sh" _TINFOIL_ALLOWED_ENCLAVE_HOSTS = frozenset({"tinfoil.sh"}) + +def _normalize_upstream_model_id(model_id: str | None) -> str: + """Normalize casing and whitespace for upstream identity comparisons.""" + if not model_id: + return "" + return model_id.strip().lower() + + # Headers that must not be forwarded to the upstream enclave. _PROXY_ONLY_HEADERS = frozenset( { @@ -331,50 +339,55 @@ async def _compute_ehbp_actual_cost( actual_model: str | None = usage_dict.pop("model", None) # type: ignore[arg-type] pricing_model_id = model_obj.id expected_upstream_model = model_obj.forwarded_model_id or model_obj.id - # Case-insensitive comparison: ``get_model_instance`` lowercases lookup - # keys, so a casing difference between the header and the configured - # ``forwarded_model_id`` (e.g. ``GLM-5-2`` vs ``glm-5-2``) should not - # be treated as a real mismatch. - if ( - actual_model - and actual_model.lower() != expected_upstream_model.lower() - ): + expected_identity = _normalize_upstream_model_id(expected_upstream_model) + served_identity = _normalize_upstream_model_id(actual_model) + + # Ignore casing and surrounding whitespace when comparing the model + # reported by the enclave with the expected upstream model. Version + # suffixes remain part of the identity because a configured + # ``forwarded_model_id`` may intentionally include one. + if actual_model and served_identity != expected_identity: from ..proxy import get_model_instance # ``forwarded_model_id`` values are registered as routable aliases in - # the global model map, so ``get_model_instance`` will find a model - # whose upstream ID matches the actually-served model. It also strips - # date-version suffixes (e.g. ``glm-5-2-20260415`` -> ``glm-5-2``), - # so a resolved model that is actually the *same* as the requested - # one is treated as a non-mismatch. + # the global model map. The resolved object can belong to a different + # provider and therefore have a different client-facing ``id`` while + # still representing the same upstream model. actual_model_obj = get_model_instance(actual_model) - if actual_model_obj and actual_model_obj.id != model_obj.id: - logger.info( - "EHBP served model differs from requested, using actual " - "model for pricing", + if actual_model_obj is None: + logger.warning( + "EHBP served model not found in registry, falling back " + "to requested model for pricing", extra={ "requested_model": model_obj.id, "expected_upstream_model": expected_upstream_model, "actual_model": actual_model, }, ) - pricing_model_id = actual_model_obj.id + actual_model = None else: - # Either the served model is not in the registry (unknown), or - # it resolves back to the requested model (e.g. a date-versioned - # alias like ``glm-5-2-20260415``). In both cases use the - # requested model's pricing and do not propagate actual_model. - if actual_model_obj is None: - logger.warning( - "EHBP served model not found in registry, falling back " - "to requested model for pricing", + resolved_upstream_model = ( + actual_model_obj.forwarded_model_id or actual_model_obj.id + ) + resolved_identity = _normalize_upstream_model_id( + resolved_upstream_model + ) + if resolved_identity != expected_identity: + logger.info( + "EHBP served model differs from requested, using actual " + "model for pricing", extra={ "requested_model": model_obj.id, "expected_upstream_model": expected_upstream_model, "actual_model": actual_model, + "resolved_upstream_model": resolved_upstream_model, }, ) - actual_model = None # do not propagate unknown / same model + pricing_model_id = actual_model_obj.id + else: + # A different registry/client alias resolved to the same + # upstream model; retain the requested model's pricing. + actual_model = None else: # Models match or no model in header — use requested model's pricing. actual_model = None diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index a5579504..0cbebdee 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -491,6 +491,8 @@ class TestComputeEhbpActualCost: model_obj.id = "tinfoil-glm-5-2" model_obj.forwarded_model_id = "glm-5-2" # lowercase with patch( + "routstr.proxy.get_model_instance" + ) as mock_get_model, patch( "routstr.upstream.ehbp.calculate_cost", new_callable=AsyncMock, ) as mock_calc: @@ -515,11 +517,7 @@ class TestComputeEhbpActualCost: # No mismatch: requested model pricing used call_args = mock_calc.call_args assert call_args[0][0]["model"] == "tinfoil-glm-5-2" - # get_model_instance must not be consulted for a casing-only diff - assert not any( - call[0] == ("GLM-5-2",) - for call in mock_calc.call_args_list - ) + mock_get_model.assert_not_called() @pytest.mark.asyncio async def test_date_versioned_alias_resolves_to_requested(self) -> None: @@ -529,15 +527,14 @@ class TestComputeEhbpActualCost: model_obj.id = "tinfoil-glm-5-2" model_obj.forwarded_model_id = "glm-5-2" - # get_model_instance strips the date suffix and returns the SAME model - actual_model_obj = MagicMock() - actual_model_obj.id = "tinfoil-glm-5-2" # identical to requested - actual_model_obj.forwarded_model_id = "glm-5-2" + resolved_model_obj = MagicMock() + resolved_model_obj.id = "other-provider-glm-5-2" + resolved_model_obj.forwarded_model_id = "glm-5-2" with patch( "routstr.proxy.get_model_instance", - return_value=actual_model_obj, - ), patch( + return_value=resolved_model_obj, + ) as mock_get_model, patch( "routstr.upstream.ehbp.calculate_cost", new_callable=AsyncMock, ) as mock_calc: @@ -552,16 +549,94 @@ class TestComputeEhbpActualCost: input_tokens=42, output_tokens=10, ) - # Tinfoil returns a date-versioned ID + # Tinfoil returns a date-versioned ID with different casing. result = await _compute_ehbp_actual_cost( - "prompt=42,completion=10,total=52,model=glm-5-2-20260415", + "prompt=42,completion=10,total=52,model=GLM-5-2-20260415", model_obj, 100_000, ) - # No mismatch — resolves to the same model + # Registry resolution, rather than unconditional suffix removal, + # establishes that this alias represents the expected model. assert "actual_model" not in result - call_args = mock_calc.call_args - assert call_args[0][0]["model"] == "tinfoil-glm-5-2" + assert mock_calc.call_args[0][0]["model"] == "tinfoil-glm-5-2" + mock_get_model.assert_called_once_with("GLM-5-2-20260415") + + @pytest.mark.asyncio + async def test_configured_date_version_is_preserved_as_identity(self) -> None: + """A date suffix in forwarded_model_id is meaningful and preserved.""" + model_obj = MagicMock() + model_obj.id = "tinfoil-glm-5-2-20260415" + model_obj.forwarded_model_id = "glm-5-2-20260415" + + with patch( + "routstr.proxy.get_model_instance" + ) as mock_get_model, patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=5, + output_msats=10, + total_msats=15, + total_usd=0.0, + input_tokens=42, + output_tokens=10, + ) + result = await _compute_ehbp_actual_cost( + "prompt=42,completion=10,total=52,model=GLM-5-2-20260415", + model_obj, + 100_000, + ) + + assert "actual_model" not in result + assert ( + mock_calc.call_args[0][0]["model"] + == "tinfoil-glm-5-2-20260415" + ) + mock_get_model.assert_not_called() + + @pytest.mark.asyncio + async def test_different_client_alias_same_upstream_identity(self) -> None: + """A global alias winner from another provider is not a failover when + its forwarded model ID matches the requested upstream identity.""" + model_obj = MagicMock() + model_obj.id = "tinfoil-glm-5-2" + model_obj.forwarded_model_id = "glm-5-2" + + resolved_model_obj = MagicMock() + resolved_model_obj.id = "other-provider-glm-5-2" + resolved_model_obj.forwarded_model_id = "GLM-5-2" + + with patch( + "routstr.proxy.get_model_instance", + return_value=resolved_model_obj, + ) as mock_get_model, patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc: + from routstr.payment.cost_calculation import CostData + + mock_calc.return_value = CostData( + base_msats=0, + input_msats=5, + output_msats=10, + total_msats=15, + total_usd=0.0, + input_tokens=42, + output_tokens=10, + ) + result = await _compute_ehbp_actual_cost( + "prompt=42,completion=10,total=52,model=provider-alias", + model_obj, + 100_000, + ) + + mock_get_model.assert_called_once_with("provider-alias") + assert "actual_model" not in result + assert mock_calc.call_args[0][0]["model"] == "tinfoil-glm-5-2" # --------------------------------------------------------------------------- From b81c5add6a2c5690a187ecc17e81129902a66919 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:40:37 +0800 Subject: [PATCH 26/27] test: satisfy mypy usage parser assertions --- tests/unit/test_tinfoil_integration.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 0cbebdee..9435a851 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -89,6 +89,7 @@ class TestParseTinfoilUsageMetrics: result = parse_tinfoil_usage_metrics( "prompt=1,completion=1,total=2,model=kimi-k2-6" ) + assert result is not None assert result["model"] == "kimi-k2-6" def test_model_with_extra_fields(self) -> None: @@ -97,6 +98,7 @@ class TestParseTinfoilUsageMetrics: "cached_prompt_tokens=64,uncached_prompt_tokens=5," "model=kimi-k2-6" ) + assert result is not None assert result["prompt_tokens"] == 69 assert result["completion_tokens"] == 20 assert result["total_tokens"] == 89 From 892aed61cc93c1c16b1a717d85f944f809c3e027 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:01:13 +0800 Subject: [PATCH 27/27] Fix mypy: move type: ignore onto ASGITransport line --- docs/ehbp-proxy-support.md | 64 ++++++++----------- docs/tinfoil-direct-integration.md | 10 +-- routstr/proxy.py | 25 +++++--- routstr/upstream/tinfoil.py | 2 +- routstr/upstream/tinfoil_trailer.py | 30 ++++++++- .../test_proxy_tinfoil_attestation_routing.py | 54 ++++++++++++++-- tests/unit/test_tinfoil_trailer.py | 48 ++++++++++++++ 7 files changed, 176 insertions(+), 57 deletions(-) diff --git a/docs/ehbp-proxy-support.md b/docs/ehbp-proxy-support.md index 05b5aad3..f9edc6e0 100644 --- a/docs/ehbp-proxy-support.md +++ b/docs/ehbp-proxy-support.md @@ -53,25 +53,22 @@ The actual EHBP forwarding logic does **not** live in `base.py`. ### `routstr/upstream/ehbp.py` -Contains the shared opaque EHBP transport helpers: +Contains the shared opaque EHBP transport and billing helpers: - `EHBPForwardingTarget` — provider-specific target URL plus extra headers -- `forward_ehbp_request()` — forwards the raw encrypted body to an EHBP-capable - provider, streams the encrypted response back untouched, and finalizes bearer - billing at max cost because usage is encrypted -- `forward_ehbp_x_cashu_request()` — redeems the Cashu token, forwards raw, - refunds the full token on upstream failure, and refunds any value above - `max_cost_for_model` on success +- `forward_ehbp_request()` — forwards the encrypted body, captures Tinfoil + usage from a response header or streaming HTTP trailer, and finalizes bearer + billing at actual cost (falling back to max cost when usage is unavailable) +- `forward_ehbp_x_cashu_request()` — redeems the Cashu token, refunds the full + token on upstream failure, and refunds the difference between the redeemed + amount and actual cost (or max cost when usage is unavailable) -### `routstr/upstream/ppqai.py` +### Provider support -- Sets `supports_ehbp = True`. -- Implements `get_ehbp_forwarding_target()` to forward to - `https://api.ppq.ai/private/v1/...` — the PPQ.AI enclave endpoint that - understands EHBP and returns the `Ehbp-Response-Nonce` header. -- Adds `X-Private-Model` with the model's `forwarded_model_id` (e.g. - `private/kimi-k2-6`). PPQ.AI's billing layer needs this since it can't - decrypt the body. +EHBP is currently enabled only for `TinfoilUpstreamProvider`. It forwards to +Tinfoil's attested enclave and requests `X-Tinfoil-Usage-Metrics` for billing. +PPQ.AI retains its private-target implementation, but `supports_ehbp = False` +until it has a provider-specific trusted usage/model-binding strategy. ## Why it's done this way @@ -84,10 +81,10 @@ The proxy is a **blind relay** for EHBP requests. It cannot decrypt the body 4. Pass through EHBP protocol headers (`Ehbp-Encapsulated-Key` on request, `Ehbp-Response-Nonce` on response) -Cost tracking happens at the proxy level using `max_cost_for_model` from the -model registry. Because EHBP responses are encrypted, Routstr cannot reconcile -against token usage. Bearer requests reserve and then finalize max-cost billing; -X-Cashu requests redeem the token and refund any amount above max cost. +Cost tracking happens at the proxy level. Routstr reserves or redeems up to +`max_cost_for_model`, then Tinfoil's out-of-band usage header/trailer allows it +to finalize at actual token cost. If trusted usage is missing or invalid, the +proxy safely falls back to max-cost billing. ## End-to-end flow @@ -125,7 +122,7 @@ Three parties see three different model IDs: | Party | Header/Body | Value | Source | |---|---|---|---| | Routstr proxy | `X-Routstr-Model` header | `tinfoil-kimi-k2-6` | SDK sends full caller-facing id | -| PPQ.AI billing | `X-Private-Model` header | `private/kimi-k2-6` | Proxy sends `forwarded_model_id` | +| Tinfoil usage metrics | `model` field | `kimi-k2-6` | Enclave reports the model actually served | | Tinfoil enclave | `body.model` (encrypted) | `kimi-k2-6` | SDK strips `tinfoil-` prefix before encryption | ## Implementation status @@ -135,24 +132,19 @@ implements the direct blind-upstream pattern described above. The shared EHBP helpers in `routstr/upstream/ehbp.py` were extended to: - Request usage metrics via `X-Tinfoil-Request-Usage-Metrics: true`. -- Parse `X-Tinfoil-Usage-Metrics` from the response header (non-streaming). -- Override the forwarding URL with `X-Tinfoil-Enclave-Url` when the SDK sends it. -- Finalize bearer billing with actual token cost via `adjust_payment_for_tokens`. +- Parse `X-Tinfoil-Usage-Metrics` from the response header (non-streaming) or + HTTP trailer (streaming). +- Override the forwarding URL with a validated `X-Tinfoil-Enclave-Url` when the + SDK sends it. +- Finalize bearer billing with the dedicated EHBP actual-cost finalizer. - Compute X-Cashu refunds from actual cost instead of max cost. See `docs/tinfoil-direct-integration.md` for the full implementation notes. -## Not yet tested +## Verification status -These changes were written without integration testing due to the complexity -of the full stack (SDK + proxy + PPQ.AI enclave + Cashu mint). Needs end-to-end -verification with a real `tinfoil-*` model request. - -Important assumptions to verify: - -- PPQ.AI accepts `/private/v1/...` with `X-Private-Model`. -- PPQ.AI enforces consistency between `X-Private-Model` and the encrypted - `body.model`, otherwise a malicious client could understate - `X-Routstr-Model` for billing. -- SDK behavior on non-2xx proxy-generated errors that do not carry - `Ehbp-Response-Nonce`. +Unit coverage includes usage parsing, target validation, HTTP trailer capture, +response-size limits, and bearer payment finalization. End-to-end requests have +verified both non-streaming usage headers and streaming usage trailers against +Tinfoil. SDK behavior on proxy-generated non-2xx responses without an +`Ehbp-Response-Nonce` still merits explicit end-to-end coverage. diff --git a/docs/tinfoil-direct-integration.md b/docs/tinfoil-direct-integration.md index 80d104c1..c647699a 100644 --- a/docs/tinfoil-direct-integration.md +++ b/docs/tinfoil-direct-integration.md @@ -485,9 +485,9 @@ back to the requested model's pricing. - ~~End-to-end test with a real Tinfoil SDK client against a Routstr node with `TINFOIL_API_KEY` set.~~ Verified: both non-streaming (header) and streaming (trailer) responses include `model=`. -- Streaming requests: usage is delivered as an HTTP trailer. Currently the - bearer path finalizes max-cost before streaming begins. Supporting streaming - usage would require buffering the response (for X-Cashu) or a deferred - finalization (for bearer). +- Streaming trailer capture is implemented by buffering the encrypted response + in `forward_with_trailer()` and then using the dedicated EHBP payment + finalizers for bearer and X-Cashu requests. This provides actual-cost billing + today, at the cost of full time-to-last-byte latency for streaming responses. - Whether Tinfoil's `/v1/responses` endpoint also returns usage metrics - headers. + headers or trailers. diff --git a/routstr/proxy.py b/routstr/proxy.py index fc67797b..a5534e00 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -106,8 +106,13 @@ def get_unique_models() -> list[Model]: def _is_tinfoil_attestation_path(path: str) -> bool: - """Return True for Tinfoil attestation-bundle proxy paths.""" - return path in {"attestation", "tee/attestation"} + """Return True for exact Tinfoil attestation routes, with optional slash.""" + return path in { + "attestation", + "attestation/", + "tee/attestation", + "tee/attestation/", + } def _select_unauthenticated_get_upstreams( @@ -234,13 +239,10 @@ async def proxy( else: model_id = request_body_dict.get("model", "unknown") - # /tee/* and /attestation GET requests don't map to models — forward - # without model/cost/auth lookups. Tinfoil attestation paths are routed - # only to Tinfoil providers so an unrelated upstream's 404 cannot - # short-circuit before the attestation proxy is tried. - if request.method == "GET" and ( - path.startswith("tee/") or path.startswith("attestation") - ): + # Exact Tinfoil attestation GET routes don't map to models — forward + # without model/cost/auth lookups. Do not prefix-match here: paths such as + # /attestationjunk must continue through normal authentication. + if request.method == "GET" and _is_tinfoil_attestation_path(path): selected_upstreams = _select_unauthenticated_get_upstreams(path, _upstreams) if not selected_upstreams: return create_error_response( @@ -255,7 +257,10 @@ async def proxy( try: headers = upstream.prepare_headers(dict(request.headers)) response = await upstream.forward_get_request(request, path, headers) - if response.status_code in [502, 429] and i < len(selected_upstreams) - 1: + if ( + response.status_code in [502, 429] + and i < len(selected_upstreams) - 1 + ): logger.warning( "Upstream %s returned %s for unauthenticated GET %s, trying next", upstream.provider_type, diff --git a/routstr/upstream/tinfoil.py b/routstr/upstream/tinfoil.py index 795ae904..6eeb1147 100644 --- a/routstr/upstream/tinfoil.py +++ b/routstr/upstream/tinfoil.py @@ -116,7 +116,7 @@ class TinfoilUpstreamProvider(BaseUpstreamProvider): EHBP-only header used for encrypted POST requests and is not honored for unencrypted GET requests. """ - clean_path = path.removeprefix("tee/") + clean_path = path.removeprefix("tee/").rstrip("/") if clean_path == "attestation": return await self._proxy_attestation(headers) return await super().forward_get_request(request, path, headers) diff --git a/routstr/upstream/tinfoil_trailer.py b/routstr/upstream/tinfoil_trailer.py index abffd6ba..0357864f 100644 --- a/routstr/upstream/tinfoil_trailer.py +++ b/routstr/upstream/tinfoil_trailer.py @@ -27,6 +27,16 @@ _READ_BUFSIZE = 65536 _DEFAULT_TIMEOUT_SECONDS = 30.0 _DEFAULT_CLOSE_TIMEOUT_SECONDS = 1.0 _DEFAULT_MAX_RESPONSE_BYTES = 25 * 1024 * 1024 +_HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} @dataclass @@ -47,6 +57,19 @@ def _get_header(headers: list[tuple[str, str]], name: str) -> str | None: return None +def _strip_hop_by_hop_headers(headers: dict[str, str]) -> dict[str, str]: + """Remove connection-specific headers before serializing a new request.""" + connection_tokens: set[str] = set() + for key, value in headers.items(): + if key.lower() == "connection": + connection_tokens.update( + token.strip().lower() for token in value.split(",") if token.strip() + ) + + excluded = _HOP_BY_HOP_HEADERS | connection_tokens + return {key: value for key, value in headers.items() if key.lower() not in excluded} + + async def forward_with_trailer( *, method: str, @@ -71,6 +94,11 @@ async def forward_with_trailer( if parsed.query: path = f"{path}?{parsed.query}" + # FastAPI has already decoded the incoming request body. Do not carry the + # original connection's framing or other hop-by-hop metadata into the new + # upstream connection. + headers = _strip_hop_by_hop_headers(headers) + ssl_ctx = ssl.create_default_context() reader, writer = await asyncio.wait_for( asyncio.open_connection(host, port, ssl=ssl_ctx), @@ -86,7 +114,7 @@ async def forward_with_trailer( header_lines.append("Connection: close") for key, value in headers.items(): - if key.lower() in ("host", "connection"): + if key.lower() == "host": continue header_lines.append(f"{key}: {value}") diff --git a/tests/unit/test_proxy_tinfoil_attestation_routing.py b/tests/unit/test_proxy_tinfoil_attestation_routing.py index 35b618ca..b6ba2f88 100644 --- a/tests/unit/test_proxy_tinfoil_attestation_routing.py +++ b/tests/unit/test_proxy_tinfoil_attestation_routing.py @@ -38,7 +38,8 @@ async def test_attestation_get_routes_directly_to_tinfoil_provider( monkeypatch.setattr(proxy_module, "_upstreams", [non_tinfoil, tinfoil]) async with AsyncClient( - transport=ASGITransport(app=proxy_app), base_url="http://test" # type: ignore[arg-type] + transport=ASGITransport(app=proxy_app), # type: ignore[arg-type] + base_url="http://test", ) as client: response = await client.get("/attestation") @@ -69,7 +70,8 @@ async def test_tee_attestation_get_routes_directly_to_tinfoil_provider( monkeypatch.setattr(proxy_module, "_upstreams", [non_tinfoil, tinfoil]) async with AsyncClient( - transport=ASGITransport(app=proxy_app), base_url="http://test" # type: ignore[arg-type] + transport=ASGITransport(app=proxy_app), # type: ignore[arg-type] + base_url="http://test", ) as client: response = await client.get("/tee/attestation") @@ -79,6 +81,48 @@ async def test_tee_attestation_get_routes_directly_to_tinfoil_provider( tinfoil.forward_get_request.assert_awaited_once() +@pytest.mark.parametrize("path", ["attestation/", "tee/attestation/"]) +@pytest.mark.asyncio +async def test_attestation_trailing_slash_routes_directly_to_tinfoil( + monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI, path: str +) -> None: + tinfoil = MagicMock() + tinfoil.provider_type = "tinfoil" + tinfoil.prepare_headers = MagicMock(return_value={}) + tinfoil.forward_get_request = AsyncMock(return_value=Response(status_code=200)) + monkeypatch.setattr(proxy_module, "_upstreams", [tinfoil]) + + async with AsyncClient( + transport=ASGITransport(app=proxy_app), # type: ignore[arg-type] + base_url="http://test", + ) as client: + response = await client.get(f"/{path}") + + assert response.status_code == 200 + tinfoil.forward_get_request.assert_awaited_once() + + +@pytest.mark.parametrize("path", ["attestation/foo", "attestationjunk"]) +@pytest.mark.asyncio +async def test_non_attestation_prefix_does_not_bypass_authentication( + monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI, path: str +) -> None: + tinfoil = MagicMock() + tinfoil.provider_type = "tinfoil" + tinfoil.forward_get_request = AsyncMock() + monkeypatch.setattr(proxy_module, "_upstreams", [tinfoil]) + + async with AsyncClient( + transport=ASGITransport(app=proxy_app), # type: ignore[arg-type] + base_url="http://test", + ) as client: + response = await client.get(f"/{path}") + + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_model" + tinfoil.forward_get_request.assert_not_awaited() + + def test_attestation_upstream_selection_is_tinfoil_only() -> None: non_tinfoil = MagicMock(provider_type="openai") tinfoil = MagicMock(provider_type="tinfoil") @@ -90,6 +134,8 @@ def test_attestation_upstream_selection_is_tinfoil_only() -> None: "tee/attestation", [non_tinfoil, tinfoil] ) == [tinfoil] assert proxy_module._select_unauthenticated_get_upstreams( - "tee/other", [non_tinfoil, tinfoil] + "attestation/", [non_tinfoil, tinfoil] + ) == [tinfoil] + assert proxy_module._select_unauthenticated_get_upstreams( + "attestationjunk", [non_tinfoil, tinfoil] ) == [non_tinfoil, tinfoil] - diff --git a/tests/unit/test_tinfoil_trailer.py b/tests/unit/test_tinfoil_trailer.py index d8319375..ef1c96f1 100644 --- a/tests/unit/test_tinfoil_trailer.py +++ b/tests/unit/test_tinfoil_trailer.py @@ -66,6 +66,54 @@ async def test_forward_with_trailer_captures_usage_trailer( writer.wait_closed.assert_awaited_once() +@pytest.mark.asyncio +async def test_forward_with_trailer_strips_hop_by_hop_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok" + reader = FakeReader([response]) + writer = FakeWriter() + monkeypatch.setattr( + "routstr.upstream.tinfoil_trailer.asyncio.open_connection", + AsyncMock(return_value=(reader, writer)), + ) + + await forward_with_trailer( + method="POST", + url="https://enclave.tinfoil.sh/v1/chat/completions", + headers={ + "Authorization": "Bearer upstream", + "Connection": "keep-alive, X-Client-Hop", + "Keep-Alive": "timeout=5", + "Proxy-Authenticate": "Basic", + "Proxy-Authorization": "Basic secret", + "TE": "trailers", + "Trailer": "X-Usage", + "Transfer-Encoding": "chunked", + "Upgrade": "websocket", + "X-Client-Hop": "remove-me", + "X-End-To-End": "preserve-me", + }, + body=b"opaque", + ) + + serialized_headers = writer.written.split(b"\r\n\r\n", 1)[0].lower() + for name in ( + b"keep-alive", + b"proxy-authenticate", + b"proxy-authorization", + b"te:", + b"trailer:", + b"transfer-encoding", + b"upgrade:", + b"x-client-hop", + ): + assert name not in serialized_headers + assert b"connection: close" in serialized_headers + assert b"content-length: 6" in serialized_headers + assert b"x-end-to-end: preserve-me" in serialized_headers + + @pytest.mark.asyncio async def test_forward_with_trailer_enforces_response_size_limit( monkeypatch: pytest.MonkeyPatch,