chore(deps): bump httpx to 0.28 and litellm to 1.84, shimming cashu's removed proxies kwarg

This commit is contained in:
9qeklajc
2026-09-09 23:46:59 +02:00
parent 2e9f550e34
commit bdbe31f1ad
9 changed files with 229 additions and 30 deletions
+1 -1
View File
@@ -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(
+9 -5
View File
@@ -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",
+98
View File
@@ -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
+3 -3
View File
@@ -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"
+5
View File
@@ -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
+5
View File
@@ -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
@@ -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
+78
View File
@@ -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
Generated
+19 -17
View File
@@ -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" }