From bdbe31f1adb20b423baf0e52f075d87cb8b7ad3b Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 23:24:41 +0200 Subject: [PATCH] chore(deps): bump httpx to 0.28 and litellm to 1.84, shimming cashu's removed proxies kwarg --- examples/tor.py | 2 +- pyproject.toml | 14 ++- routstr/cashu_compat.py | 98 +++++++++++++++++++ routstr/nostr/discovery.py | 6 +- routstr/payment/lnurl.py | 5 + routstr/wallet.py | 5 + .../test_admin_pricing_rate_validation.py | 15 ++- tests/unit/test_cashu_httpx_compat.py | 78 +++++++++++++++ uv.lock | 36 +++---- 9 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 routstr/cashu_compat.py create mode 100644 tests/unit/test_cashu_httpx_compat.py diff --git a/examples/tor.py b/examples/tor.py index 7e67bb87..a534c005 100644 --- a/examples/tor.py +++ b/examples/tor.py @@ -7,7 +7,7 @@ from openai import OpenAI client = OpenAI( api_key=os.environ.get("TOKEN"), base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"), - http_client=httpx.Client(proxies="socks5://localhost:9050"), + http_client=httpx.Client(proxy="socks5://localhost:9050"), ) print( diff --git a/pyproject.toml b/pyproject.toml index 38f1ac94..2b67f965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi[standard-no-fastapi-cloud-cli]>=0.141", "aiosqlite>=0.20", "sqlmodel>=0.0.24", - "httpx[socks]>=0.25.2", + "httpx[socks]>=0.28.1", "h11>=0.16", "greenlet>=3.2.1", "alembic>=1.13", @@ -21,7 +21,7 @@ dependencies = [ "mdurl==0.1.2", "pillow>=10", "openai>=1.98.0", - "litellm>=1.55.0", + "litellm>=1.84.0,<1.85", ] [dependency-groups] @@ -88,11 +88,15 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } -# Security floors. cashu 0.20.x caps h11, fastapi, cryptography, setuptools -# and wheel below their patched versions. routstr uses only cashu's wallet -# modules, not its server paths, so the caps are safe to lift here. +# Security floors. cashu 0.20.x caps httpx, h11, fastapi, cryptography, +# setuptools and wheel below their patched versions. routstr uses only cashu's +# wallet modules, not its server paths, so the caps are safe to lift here. [tool.uv] override-dependencies = [ + # httpx 0.28 (required by litellm 1.84) removed the `proxies` kwarg cashu + # passes on every mint call; routstr/cashu_compat.py restores it for cashu. + "httpx[socks]>=0.28.1,<1.0", + "importlib-metadata>=8.0.0,<9.0", "h11>=0.16.0", "fastapi[standard-no-fastapi-cloud-cli]>=0.141", "cryptography>=49.0.0", diff --git a/routstr/cashu_compat.py b/routstr/cashu_compat.py new file mode 100644 index 00000000..d97df06b --- /dev/null +++ b/routstr/cashu_compat.py @@ -0,0 +1,98 @@ +"""Compatibility shim that keeps cashu 0.20.x working on httpx>=0.28. + +cashu's ``async_set_httpx_client`` decorator builds the client for *every* mint +call as ``httpx.AsyncClient(proxies=proxies_dict, ...)``. httpx deprecated +``proxies`` in 0.26 and removed it in 0.28, so on httpx>=0.28 every wallet +operation routstr performs -- ``load_mint_keysets``, ``mint_quote``, +``melt_quote``, token redeem -- raises:: + + TypeError: AsyncClient.__init__() got an unexpected keyword argument 'proxies' + +Upstream cashu (0.20.3, the latest release) still caps ``httpx<0.26`` and has no +release that fixes this, while litellm>=1.84 requires ``httpx>=0.28``. We bridge +the gap by translating the keyword *inside cashu's module namespace only* -- +global httpx behaviour is untouched, and the shim is a no-op on httpx<0.28. + +Delete this module once cashu ships a release that passes ``proxy=``. +""" + +from typing import Any + +import httpx + +__all__ = ["install_cashu_httpx_shim"] + +_ALL_SCHEMES = "all://" + + +def _single_proxy(proxies: Any) -> str | None: + """Collapse an httpx<0.28 ``proxies`` mapping into a single ``proxy`` URL. + + cashu only ever builds ``{}`` or ``{"all://": url}``, so a mapping with one + distinct URL is all we need to support; anything richer is unrepresentable + as httpx 0.28's scalar ``proxy=`` and is dropped rather than guessed at. + """ + if not proxies: + return None + if isinstance(proxies, str): + return proxies + if isinstance(proxies, dict): + if _ALL_SCHEMES in proxies: + value = proxies[_ALL_SCHEMES] + return str(value) if value is not None else None + distinct = {str(v) for v in proxies.values() if v is not None} + if len(distinct) == 1: + return distinct.pop() + return None + + +class _ProxiesCompatAsyncClient(httpx.AsyncClient): + """``httpx.AsyncClient`` that still accepts the removed ``proxies`` kwarg.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + if "proxies" in kwargs: + proxies = kwargs.pop("proxies") + proxy = _single_proxy(proxies) + if proxy is not None and kwargs.get("proxy") is None: + kwargs["proxy"] = proxy + super().__init__(*args, **kwargs) + + +class _HttpxNamespace: + """Stand-in for the ``httpx`` module inside cashu's ``v1_api``. + + Every attribute resolves against the real module except ``AsyncClient``, + so cashu keeps using genuine httpx types everywhere else. + """ + + AsyncClient = _ProxiesCompatAsyncClient + + def __getattr__(self, name: str) -> Any: + return getattr(httpx, name) + + +def _httpx_accepts_proxies() -> bool: + import inspect + + try: + return "proxies" in inspect.signature(httpx.AsyncClient.__init__).parameters + except (TypeError, ValueError): # pragma: no cover - defensive + return False + + +def install_cashu_httpx_shim() -> bool: + """Patch cashu's mint client to survive httpx>=0.28. + + Returns True when the shim was installed, False when it wasn't needed. + Safe to call repeatedly. + """ + if _httpx_accepts_proxies(): + return False + + from cashu.wallet import v1_api + + if isinstance(getattr(v1_api, "httpx", None), _HttpxNamespace): + return True + + v1_api.httpx = _HttpxNamespace() # type: ignore[assignment] + return True diff --git a/routstr/nostr/discovery.py b/routstr/nostr/discovery.py index 268b4c7c..ce61d4e3 100644 --- a/routstr/nostr/discovery.py +++ b/routstr/nostr/discovery.py @@ -320,18 +320,18 @@ async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]: is_onion = ".onion" in endpoint_url # Set up client arguments conditionally - proxies = None + proxy: str | None = None if is_onion: try: tor_proxy = settings.tor_proxy_url except Exception: tor_proxy = "socks5://127.0.0.1:9050" - proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment] + proxy = tor_proxy async with httpx.AsyncClient( timeout=httpx.Timeout(30.0), follow_redirects=True, - proxies=proxies, # type: ignore[arg-type] + proxy=proxy, ) as client: # Prefer provider's /v1/info for full details info_url = f"{endpoint_url.rstrip('/')}/v1/info" diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index def5bca7..2f5c59a6 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -8,12 +8,17 @@ import httpx from cashu.core.base import MeltQuoteState from cashu.wallet.wallet import Proof, Wallet +from ..cashu_compat import install_cashu_httpx_shim from ..mint import ( is_mint_rate_limited, is_mint_transport_error, run_mint_operation, ) +# cashu 0.20.x passes the `proxies` kwarg httpx removed in 0.28; see the module +# docstring. Installed at import so no mint call can run before the patch. +install_cashu_httpx_shim() + try: from bech32 import bech32_decode, convertbits # type: ignore except ModuleNotFoundError: # pragma: no cover – allow runtime miss diff --git a/routstr/wallet.py b/routstr/wallet.py index 5e1588a0..910d77e3 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -20,6 +20,7 @@ from cashu.wallet.wallet import Wallet as _CashuWallet from pydantic_core import PydanticUndefined from sqlmodel import col, select, update +from .cashu_compat import install_cashu_httpx_shim from .core import db, get_logger from .core.db import store_cashu_transaction_with_retry as store_cashu_transaction from .core.settings import settings @@ -36,6 +37,10 @@ from .mint import ( ) from .payment.lnurl import raw_send_to_lnurl +# cashu 0.20.x passes the `proxies` kwarg httpx removed in 0.28; see the module +# docstring. Installed at import so no mint call can run before the patch. +install_cashu_httpx_shim() + # Backwards-compatible aliases for callers/tests that imported the former # wallet-local policy. Production modules use the public routstr.mint API. _MintRateGuard = MintRateGuard diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py index 2e9a45b9..fb6cfa18 100644 --- a/tests/integration/test_admin_pricing_rate_validation.py +++ b/tests/integration/test_admin_pricing_rate_validation.py @@ -410,12 +410,19 @@ async def test_malformed_auxiliary_rate_is_rejected( ("input_cache_write", float("-inf")), ("completion", -1.0), ): + # Send raw bytes rather than `json=`: httpx>=0.28 refuses to encode + # non-finite floats itself (allow_nan=False), but the point of this + # test is that the SERVER answers the bare NaN/Infinity literals + # with a 422, so the literals must still reach it. + body = json.dumps( + _payload( + provider_id, model_id="aux-rate", pricing=_pricing(**{field: bad}) + ) + ).encode("utf-8") resp = await integration_client.post( f"/admin/api/upstream-providers/{provider_id}/models", - headers=_admin_headers(), - json=_payload( - provider_id, model_id="aux-rate", pricing=_pricing(**{field: bad}) - ), + headers={**_admin_headers(), "content-type": "application/json"}, + content=body, ) assert resp.status_code == 422, field diff --git a/tests/unit/test_cashu_httpx_compat.py b/tests/unit/test_cashu_httpx_compat.py new file mode 100644 index 00000000..191b7b56 --- /dev/null +++ b/tests/unit/test_cashu_httpx_compat.py @@ -0,0 +1,78 @@ +"""cashu 0.20.x builds its mint client with the `proxies` kwarg httpx removed in +0.28. These tests pin the shim that keeps every wallet call working.""" + +import httpx +import pytest +from cashu.wallet import v1_api +from httpx import AsyncClient + +from routstr.cashu_compat import ( + _ProxiesCompatAsyncClient, + _single_proxy, + install_cashu_httpx_shim, +) + + +def test_httpx_no_longer_accepts_proxies() -> None: + """The premise of the shim: plain httpx rejects what cashu passes.""" + with pytest.raises(TypeError): + httpx.AsyncClient(proxies={}) # type: ignore[call-arg] + + +@pytest.mark.parametrize( + "proxies, expected", + [ + ({}, None), + (None, None), + ({"all://": "socks5://localhost:9050"}, "socks5://localhost:9050"), + ("socks5://localhost:9050", "socks5://localhost:9050"), + ({"http://": "http://p:1", "https://": "http://p:1"}, "http://p:1"), + ({"http://": "http://a:1", "https://": "http://b:2"}, None), + ], +) +def test_single_proxy_collapses_cashu_mappings( + proxies: object, expected: str | None +) -> None: + assert _single_proxy(proxies) == expected + + +@pytest.mark.parametrize("proxies", [{}, {"all://": "socks5://localhost:9050"}]) +async def test_compat_client_accepts_proxies(proxies: dict) -> None: + async with _ProxiesCompatAsyncClient( + proxies=proxies, base_url="http://mint.test" + ) as client: + assert isinstance(client, httpx.AsyncClient) + + +async def test_cashu_decorator_builds_a_client_after_shim() -> None: + """The real cashu decorator — the code path every mint call goes through.""" + install_cashu_httpx_shim() + + class _Ledger: + url = "http://mint.test/" + # cashu's decorator assigns the client here; alias avoids shadowing. + httpx: AsyncClient + + @v1_api.async_set_httpx_client # type: ignore[misc] + async def call(self) -> AsyncClient: + return self.httpx + + client: httpx.AsyncClient = await _Ledger().call() + try: + assert isinstance(client, httpx.AsyncClient) + assert str(client.base_url) == "http://mint.test" + finally: + await client.aclose() + + +def test_install_is_idempotent() -> None: + assert install_cashu_httpx_shim() is True + patched = v1_api.httpx + assert install_cashu_httpx_shim() is True + assert v1_api.httpx is patched + + +def test_shim_namespace_passes_through_other_httpx_attributes() -> None: + install_cashu_httpx_shim() + assert v1_api.httpx.Response is httpx.Response + assert v1_api.httpx.AsyncClient is _ProxiesCompatAsyncClient diff --git a/uv.lock b/uv.lock index 18e9d483..41143715 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,8 @@ overrides = [ { name = "cryptography", specifier = ">=49.0.0" }, { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.141" }, { name = "h11", specifier = ">=0.16.0" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0" }, + { name = "importlib-metadata", specifier = ">=8.0.0,<9.0" }, { name = "setuptools", specifier = ">=83.0.0" }, { name = "wheel", specifier = ">=0.46.2" }, ] @@ -939,7 +941,7 @@ wheels = [ standard-no-fastapi-cloud-cli = [ { name = "email-validator" }, { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, { name = "pydantic-extra-types" }, { name = "pydantic-settings" }, @@ -1341,18 +1343,17 @@ wheels = [ [[package]] name = "httpx" -version = "0.25.2" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, - { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/23/911d93a022979d3ea295f659fbe7edb07b3f4561a477e83b3a6d0e0c914e/httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8", size = 123889, upload-time = "2023-11-24T12:36:33.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/65/6940eeb21dcb2953778a6895281c179efd9100463ff08cb6232bb6480da7/httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118", size = 74980, upload-time = "2023-11-24T12:36:31.403Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [package.optional-dependencies] @@ -1368,7 +1369,7 @@ dependencies = [ { name = "filelock" }, { name = "fsspec" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "packaging" }, { name = "pyyaml" }, { name = "tqdm" }, @@ -1391,14 +1392,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "6.11.0" +version = "8.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/eb/58c2ab27ee628ad801f56d4017fe62afab0293116f6d0b08f1d5bd46e06f/importlib_metadata-6.11.0.tar.gz", hash = "sha256:1231cf92d825c9e03cfc4da076a16de6422c863558229ea0b22b675657463443", size = 54593, upload-time = "2023-12-03T17:33:10.693Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9b/ecce94952ab5ea74c31dcf9ccf78ccd484eebebef06019bf8cb579ab4519/importlib_metadata-6.11.0-py3-none-any.whl", hash = "sha256:f0afba6205ad8f8947c7d338b5342d5db2afbfd82f9cbef7879a9539cc12eb9b", size = 23427, upload-time = "2023-12-03T17:33:08.965Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f", size = 27220, upload-time = "2026-03-20T16:56:25.07Z" }, ] [[package]] @@ -1525,13 +1526,13 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.0" +version = "1.84.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, { name = "fastuuid" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "importlib-metadata" }, { name = "jinja2" }, { name = "jsonschema" }, @@ -1541,9 +1542,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/92/6ce9737554994ca8e536e5f4f6a87cc7c4774b656c9eb9add071caf7d54b/litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a", size = 17333062, upload-time = "2026-03-31T05:08:25.331Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c4/512c8cb204450b585bb7bee2cef9466c8b79b90cf774766f319de5c444ed/litellm-1.84.10.tar.gz", hash = "sha256:5ccb6aec803c35f463a7ea1a446030fe99f7c556388b435dc2fb7ad91aa48a24", size = 15123874, upload-time = "2026-06-24T03:57:19.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/2c/a670cc050fcd6f45c6199eb99e259c73aea92edba8d5c2fc1b3686d36217/litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8", size = 15610306, upload-time = "2026-03-31T05:08:21.987Z" }, + { url = "https://files.pythonhosted.org/packages/5b/88/e45bcdefc7a85bbef8eb852111dd4e02b92ea25727b6009c78893a768deb/litellm-1.84.10-py3-none-any.whl", hash = "sha256:7e175ebec04aa92149794adc83e4dd82b60d2b833c1ec265d68c08e8f56edde5", size = 16753091, upload-time = "2026-06-24T03:57:16.759Z" }, ] [[package]] @@ -1825,7 +1826,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, @@ -2579,7 +2580,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "aiohttp" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "mypy" }, { name = "openai" }, { name = "psutil" }, @@ -2599,8 +2600,8 @@ requires-dist = [ { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.141" }, { name = "greenlet", specifier = ">=3.2.1" }, { name = "h11", specifier = ">=0.16" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.25.2" }, - { name = "litellm", specifier = ">=1.55.0" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, + { name = "litellm", specifier = ">=1.84.0,<1.85" }, { name = "marshmallow", specifier = ">=3.13,<4.0" }, { name = "mdurl", specifier = "==0.1.2" }, { name = "nostr", specifier = ">=0.0.2" }, @@ -2842,6 +2843,7 @@ version = "2.0.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "importlib-metadata" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/03/a0af991e3a43174d6b83fca4fb399745abceddd1171bdabae48ce877ff47/sqlalchemy-2.0.42.tar.gz", hash = "sha256:160bedd8a5c28765bd5be4dec2d881e109e33b34922e50a3b881a7681773ac5f", size = 9749972, upload-time = "2025-07-29T12:48:09.323Z" }