mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Merge branch 'v0.4.0' into cache-x-cashu-tokens
# Conflicts: # routstr/core/admin.py
This commit is contained in:
+14
-1
@@ -126,11 +126,24 @@ def create_model_mappings(
|
|||||||
if isinstance(db_id, int):
|
if isinstance(db_id, int):
|
||||||
providers_by_db_id[db_id] = upstream
|
providers_by_db_id[db_id] = upstream
|
||||||
|
|
||||||
|
# Group upstreams by URL and keep only the one with the lowest fee for each URL
|
||||||
|
upstreams_by_url: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||||
|
for upstream in upstreams:
|
||||||
|
url = getattr(upstream, "base_url", "")
|
||||||
|
if url not in upstreams_by_url:
|
||||||
|
upstreams_by_url[url] = []
|
||||||
|
upstreams_by_url[url].append(upstream)
|
||||||
|
|
||||||
|
filtered_upstreams: list["BaseUpstreamProvider"] = []
|
||||||
|
for providers in upstreams_by_url.values():
|
||||||
|
best_provider = min(providers, key=lambda p: p.provider_fee)
|
||||||
|
filtered_upstreams.append(best_provider)
|
||||||
|
|
||||||
# Separate OpenRouter from other providers
|
# Separate OpenRouter from other providers
|
||||||
openrouter: "BaseUpstreamProvider" | None = None
|
openrouter: "BaseUpstreamProvider" | None = None
|
||||||
other_upstreams: list["BaseUpstreamProvider"] = []
|
other_upstreams: list["BaseUpstreamProvider"] = []
|
||||||
|
|
||||||
for upstream in upstreams:
|
for upstream in filtered_upstreams:
|
||||||
base_url = getattr(upstream, "base_url", "")
|
base_url = getattr(upstream, "base_url", "")
|
||||||
if base_url == "https://openrouter.ai/api/v1":
|
if base_url == "https://openrouter.ai/api/v1":
|
||||||
openrouter = upstream
|
openrouter = upstream
|
||||||
|
|||||||
+99
-45
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -873,12 +874,6 @@ async def initiate_provider_topup(
|
|||||||
if not provider:
|
if not provider:
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
upstream_instance = _instantiate_provider(provider)
|
|
||||||
if not upstream_instance:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="Could not instantiate provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Initiating top-up for provider {provider_id}",
|
f"Initiating top-up for provider {provider_id}",
|
||||||
@@ -892,41 +887,69 @@ async def initiate_provider_topup(
|
|||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
clean_url = provider.base_url.rstrip("/")
|
clean_url = provider.base_url.rstrip("/")
|
||||||
# Proxy the request to upstream Routstr
|
request_json = {
|
||||||
# Use the actual API key from the database
|
"amount_sats": int(payload.amount),
|
||||||
resp = await client.post(
|
"purpose": "topup",
|
||||||
f"{clean_url}/v1/balance/lightning/invoice",
|
"api_key": provider.api_key,
|
||||||
json={
|
}
|
||||||
"amount_sats": int(payload.amount),
|
headers = (
|
||||||
"purpose": "topup",
|
{"Authorization": f"Bearer {provider.api_key}"}
|
||||||
"api_key": provider.api_key,
|
|
||||||
},
|
|
||||||
headers={"Authorization": f"Bearer {provider.api_key}"}
|
|
||||||
if provider.api_key
|
if provider.api_key
|
||||||
else {},
|
else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code == 200:
|
last_status_code = 500
|
||||||
data = resp.json()
|
last_error_detail: object = "Failed to create top-up invoice"
|
||||||
return {
|
|
||||||
"ok": True,
|
# Some upstream Routstr nodes fail the first invoice request after warm-up
|
||||||
"topup_data": {
|
# and succeed immediately on retry. Retry once here so the UI stays single-click.
|
||||||
"payment_request": data.get("bolt11"),
|
for attempt in range(2):
|
||||||
"invoice_id": data.get("invoice_id"),
|
resp = await client.post(
|
||||||
"status": "pending",
|
f"{clean_url}/v1/balance/lightning/invoice",
|
||||||
},
|
json=request_json,
|
||||||
}
|
headers=headers,
|
||||||
else:
|
|
||||||
logger.error(f"Upstream topup request failed: {resp.text}")
|
|
||||||
# Check if it's JSON error
|
|
||||||
try:
|
|
||||||
error_detail = resp.json()
|
|
||||||
except Exception:
|
|
||||||
error_detail = resp.text
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=resp.status_code, detail=error_detail
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"topup_data": {
|
||||||
|
"payment_request": data.get("bolt11"),
|
||||||
|
"invoice_id": data.get("invoice_id"),
|
||||||
|
"status": "pending",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Upstream topup request failed: {resp.text}",
|
||||||
|
extra={
|
||||||
|
"provider_id": provider_id,
|
||||||
|
"attempt": attempt + 1,
|
||||||
|
"status_code": resp.status_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
last_error_detail = resp.json()
|
||||||
|
except Exception:
|
||||||
|
last_error_detail = resp.text
|
||||||
|
last_status_code = resp.status_code
|
||||||
|
|
||||||
|
if resp.status_code < 500 or attempt == 1:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=last_status_code, detail=last_error_detail
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream_instance = _instantiate_provider(provider)
|
||||||
|
if not upstream_instance:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Could not instantiate provider"
|
||||||
|
)
|
||||||
|
|
||||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1037,15 +1060,46 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
|||||||
if provider.provider_type == "routstr":
|
if provider.provider_type == "routstr":
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
clean_url = provider.base_url.rstrip("/")
|
||||||
clean_url = provider.base_url.rstrip("/")
|
headers = {}
|
||||||
headers = {}
|
if provider.api_key:
|
||||||
if provider.api_key:
|
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
|
||||||
resp = await client.get(
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
f"{clean_url}/v1/balance/info",
|
try:
|
||||||
headers=headers,
|
resp = await client.get(
|
||||||
)
|
f"{clean_url}/v1/balance/info",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
logger.error(
|
||||||
|
"Timed out fetching Routstr provider balance",
|
||||||
|
extra={
|
||||||
|
"provider_id": provider_id,
|
||||||
|
"base_url": clean_url,
|
||||||
|
"upstream_url": f"{clean_url}/v1/balance/info",
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504,
|
||||||
|
detail="Timed out contacting upstream Routstr provider",
|
||||||
|
) from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to fetch Routstr provider balance",
|
||||||
|
extra={
|
||||||
|
"provider_id": provider_id,
|
||||||
|
"base_url": clean_url,
|
||||||
|
"upstream_url": f"{clean_url}/v1/balance/info",
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="Failed to contact upstream Routstr provider",
|
||||||
|
) from exc
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# Return balance in sats
|
# Return balance in sats
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
|||||||
Balance in satoshis, or None if failed
|
Balance in satoshis, or None if failed
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}/v1/balance/info"
|
url = f"{self.base_url}/v1/balance/info"
|
||||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.core.admin import admin_sessions
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_routstr_provider() -> UpstreamProviderRow:
|
||||||
|
return UpstreamProviderRow(
|
||||||
|
provider_type="routstr",
|
||||||
|
base_url="https://upstream.example",
|
||||||
|
api_key="",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_headers() -> dict[str, str]:
|
||||||
|
token = "test-admin-token"
|
||||||
|
admin_sessions[token] = int(
|
||||||
|
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_routstr_provider_balance_timeout_returns_504(
|
||||||
|
integration_client: httpx.AsyncClient,
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
provider = await _create_routstr_provider()
|
||||||
|
integration_session.add(provider)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(provider)
|
||||||
|
|
||||||
|
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
|
||||||
|
timeout_error = httpx.ConnectTimeout("Connect timeout", request=request)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"httpx.AsyncHTTPTransport.handle_async_request",
|
||||||
|
new=AsyncMock(side_effect=timeout_error),
|
||||||
|
):
|
||||||
|
response = await integration_client.get(
|
||||||
|
f"/admin/api/upstream-providers/{provider.id}/balance",
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 504
|
||||||
|
assert response.json()["detail"] == "Timed out contacting upstream Routstr provider"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_routstr_provider_balance_request_error_returns_502(
|
||||||
|
integration_client: httpx.AsyncClient,
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
provider = await _create_routstr_provider()
|
||||||
|
integration_session.add(provider)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(provider)
|
||||||
|
|
||||||
|
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
|
||||||
|
request_error = httpx.ConnectError("Connection failed", request=request)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"httpx.AsyncHTTPTransport.handle_async_request",
|
||||||
|
new=AsyncMock(side_effect=request_error),
|
||||||
|
):
|
||||||
|
response = await integration_client.get(
|
||||||
|
f"/admin/api/upstream-providers/{provider.id}/balance",
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 502
|
||||||
|
assert response.json()["detail"] == "Failed to contact upstream Routstr provider"
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any, AsyncGenerator, cast
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from routstr.core.db import AsyncSession, ModelRow, UpstreamProviderRow
|
||||||
|
from routstr.payment.models import Architecture, Model, Pricing
|
||||||
|
from routstr.proxy import refresh_model_maps
|
||||||
|
from routstr.upstream.base import BaseUpstreamProvider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enforce_lowest_provider_fee_for_same_url(
|
||||||
|
integration_session: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the algorithm selects the provider with the lowest fee when URLs match."""
|
||||||
|
|
||||||
|
# 1. Create two providers with the same URL but different fees
|
||||||
|
url = "https://api.example.com"
|
||||||
|
p1 = UpstreamProviderRow(
|
||||||
|
provider_type="custom",
|
||||||
|
base_url=url,
|
||||||
|
api_key="key1",
|
||||||
|
enabled=True,
|
||||||
|
provider_fee=1.01,
|
||||||
|
)
|
||||||
|
p2 = UpstreamProviderRow(
|
||||||
|
provider_type="custom",
|
||||||
|
base_url=url,
|
||||||
|
api_key="key2",
|
||||||
|
enabled=True,
|
||||||
|
provider_fee=1.05,
|
||||||
|
)
|
||||||
|
|
||||||
|
integration_session.add(p1)
|
||||||
|
integration_session.add(p2)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(p1)
|
||||||
|
await integration_session.refresh(p2)
|
||||||
|
|
||||||
|
assert p1.id is not None
|
||||||
|
assert p2.id is not None
|
||||||
|
|
||||||
|
# 2. Add a model for each provider
|
||||||
|
m1 = ModelRow(
|
||||||
|
id="model-a",
|
||||||
|
name="Model A",
|
||||||
|
created=1,
|
||||||
|
description="desc",
|
||||||
|
context_length=100,
|
||||||
|
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
|
||||||
|
pricing='{"prompt": 1.0, "completion": 1.0}',
|
||||||
|
upstream_provider_id=p1.id,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
m2 = ModelRow(
|
||||||
|
id="model-a",
|
||||||
|
name="Model A",
|
||||||
|
created=1,
|
||||||
|
description="desc",
|
||||||
|
context_length=100,
|
||||||
|
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
|
||||||
|
pricing='{"prompt": 1.0, "completion": 1.0}',
|
||||||
|
upstream_provider_id=p2.id,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
integration_session.add(m1)
|
||||||
|
integration_session.add(m2)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# 3. Create mock provider instances
|
||||||
|
class MockProvider(BaseUpstreamProvider):
|
||||||
|
db_id: int
|
||||||
|
|
||||||
|
def __init__(self, db_id: int, base_url: str, api_key: str, fee: float):
|
||||||
|
super().__init__(base_url, api_key, fee)
|
||||||
|
self.db_id = db_id
|
||||||
|
self.provider_type = "custom"
|
||||||
|
|
||||||
|
def get_cached_models(self) -> list[Model]:
|
||||||
|
return [
|
||||||
|
Model(
|
||||||
|
id="model-a",
|
||||||
|
name="Model A",
|
||||||
|
created=1,
|
||||||
|
description="desc",
|
||||||
|
context_length=100,
|
||||||
|
architecture=Architecture(
|
||||||
|
modality="text",
|
||||||
|
input_modalities=["text"],
|
||||||
|
output_modalities=["text"],
|
||||||
|
tokenizer="tiktoken",
|
||||||
|
instruct_type="chat",
|
||||||
|
),
|
||||||
|
pricing=Pricing(prompt=1.0, completion=1.0),
|
||||||
|
enabled=True,
|
||||||
|
upstream_provider_id=self.db_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def refresh_models_cache(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def prepare_headers(self, request_headers: dict[str, str]) -> dict[str, str]:
|
||||||
|
return request_headers
|
||||||
|
|
||||||
|
# 4. Inject mock providers into the proxy
|
||||||
|
from routstr import proxy
|
||||||
|
|
||||||
|
assert p1.id is not None
|
||||||
|
assert p2.id is not None
|
||||||
|
|
||||||
|
# Need to patch proxy._upstreams and proxy.create_session
|
||||||
|
mp1: MockProvider = MockProvider(p1.id, url, "key1", 1.01)
|
||||||
|
mp2: MockProvider = MockProvider(p2.id, url, "key2", 1.05)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.proxy._upstreams", [mp1, mp2]),
|
||||||
|
patch("routstr.proxy.create_session") as mock_session_factory,
|
||||||
|
):
|
||||||
|
# Configure mock_session_factory to return a session that uses the test engine
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_create_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
yield integration_session
|
||||||
|
|
||||||
|
mock_session_factory.return_value = mock_create_session()
|
||||||
|
|
||||||
|
await refresh_model_maps()
|
||||||
|
|
||||||
|
# 5. Check which provider is selected for 'model-a'
|
||||||
|
provider_map = proxy.get_provider_for_model("model-a")
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
assert provider_map is not None
|
||||||
|
assert len(provider_map) >= 1
|
||||||
|
|
||||||
|
# Check the first one, cast to MockProvider to access db_id
|
||||||
|
best_provider = cast(MockProvider, provider_map[0])
|
||||||
|
assert best_provider.db_id == p1.id
|
||||||
|
assert best_provider.provider_fee == 1.01
|
||||||
@@ -3,12 +3,16 @@ Integration tests for provider management functionality.
|
|||||||
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from types import TracebackType
|
||||||
from typing import Any, Generator
|
from typing import Any, Generator
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from routstr.core.admin import admin_sessions
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
||||||
|
|
||||||
from .utils import ResponseValidator
|
from .utils import ResponseValidator
|
||||||
@@ -678,3 +682,88 @@ async def test_no_database_changes_during_provider_operations(
|
|||||||
assert final_diff["api_keys"]["added"] == []
|
assert final_diff["api_keys"]["added"] == []
|
||||||
assert final_diff["api_keys"]["modified"] == []
|
assert final_diff["api_keys"]["modified"] == []
|
||||||
assert final_diff["api_keys"]["removed"] == []
|
assert final_diff["api_keys"]["removed"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_routstr_topup_retries_transient_upstream_failure(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
integration_session: Any,
|
||||||
|
) -> None:
|
||||||
|
admin_token = "test-admin-token"
|
||||||
|
admin_sessions[admin_token] = int(time.time()) + 3600
|
||||||
|
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
|
||||||
|
|
||||||
|
provider = UpstreamProviderRow(
|
||||||
|
provider_type="routstr",
|
||||||
|
base_url="https://node.example",
|
||||||
|
api_key="sk-upstream-test",
|
||||||
|
enabled=True,
|
||||||
|
provider_fee=1.01,
|
||||||
|
)
|
||||||
|
integration_session.add(provider)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(provider)
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
def __init__(self, status_code: int, data: dict[str, Any] | None = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._data = data or {}
|
||||||
|
self.text = str(self._data)
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
class MockAsyncClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "MockAsyncClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
tb: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self, url: str, json: dict[str, Any], headers: dict[str, str]
|
||||||
|
) -> MockResponse:
|
||||||
|
self.calls += 1
|
||||||
|
assert url == "https://node.example/v1/balance/lightning/invoice"
|
||||||
|
assert json["amount_sats"] == 10
|
||||||
|
assert json["purpose"] == "topup"
|
||||||
|
assert json["api_key"] == "sk-upstream-test"
|
||||||
|
assert headers["Authorization"] == "Bearer sk-upstream-test"
|
||||||
|
|
||||||
|
if self.calls == 1:
|
||||||
|
return MockResponse(500, {"detail": "warmup failure"})
|
||||||
|
|
||||||
|
return MockResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"bolt11": "lnbc1testinvoice",
|
||||||
|
"invoice_id": "invoice-123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = MockAsyncClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
response = await integration_client.post(
|
||||||
|
f"/admin/api/upstream-providers/{provider.id}/topup",
|
||||||
|
json={"amount": 10},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
|
||||||
|
assert data["topup_data"]["invoice_id"] == "invoice-123"
|
||||||
|
assert mock_client.calls == 2
|
||||||
|
finally:
|
||||||
|
admin_sessions.pop(admin_token, None)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from types import TracebackType
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from routstr.upstream.routstr import RoutstrUpstreamProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DummyAsyncClient:
|
||||||
|
def __init__(self, response: Mock | None = None, error: Exception | None = None):
|
||||||
|
self.response = response
|
||||||
|
self.error = error
|
||||||
|
self.calls: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "DummyAsyncClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
tb: TracebackType | None,
|
||||||
|
) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self, url: str, headers: dict[str, str], timeout: float
|
||||||
|
) -> Mock:
|
||||||
|
self.calls.append({"url": url, "headers": headers, "timeout": timeout})
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
assert self.response is not None
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_balance_omits_auth_header_when_api_key_missing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
response = Mock()
|
||||||
|
response.json.return_value = {"balance_msats": 42000}
|
||||||
|
response.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
client = DummyAsyncClient(response=response)
|
||||||
|
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
|
||||||
|
|
||||||
|
provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="")
|
||||||
|
|
||||||
|
balance = await provider.get_balance()
|
||||||
|
|
||||||
|
assert balance == 42.0
|
||||||
|
assert client.calls == [
|
||||||
|
{
|
||||||
|
"url": "https://node.example/v1/balance/info",
|
||||||
|
"headers": {},
|
||||||
|
"timeout": 10.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_balance_returns_none_on_connect_timeout(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out"))
|
||||||
|
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
|
||||||
|
|
||||||
|
provider = RoutstrUpstreamProvider(
|
||||||
|
base_url="https://node.example",
|
||||||
|
api_key="secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
balance = await provider.get_balance()
|
||||||
|
|
||||||
|
assert balance is None
|
||||||
@@ -23,11 +23,15 @@ import {
|
|||||||
interface ProviderBalanceProps {
|
interface ProviderBalanceProps {
|
||||||
providerId: number;
|
providerId: number;
|
||||||
platformUrl?: string | null;
|
platformUrl?: string | null;
|
||||||
|
isRoutstr?: boolean;
|
||||||
|
nodeUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderBalance({
|
export function ProviderBalance({
|
||||||
providerId,
|
providerId,
|
||||||
platformUrl,
|
platformUrl,
|
||||||
|
isRoutstr = false,
|
||||||
|
nodeUrl,
|
||||||
}: ProviderBalanceProps) {
|
}: ProviderBalanceProps) {
|
||||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||||
const [topupAmount, setTopupAmount] = useState('');
|
const [topupAmount, setTopupAmount] = useState('');
|
||||||
@@ -100,14 +104,23 @@ export function ProviderBalance({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleTopup = () => {
|
const handleTopup = () => {
|
||||||
const amount = parseFloat(topupAmount);
|
const amount = Number(topupAmount);
|
||||||
|
|
||||||
if (isNaN(amount)) {
|
if (Number.isNaN(amount)) {
|
||||||
setTopupError('Please enter a valid amount');
|
setTopupError(
|
||||||
|
isRoutstr
|
||||||
|
? 'Please enter a valid amount in sats'
|
||||||
|
: 'Please enter a valid amount'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount < 1 || amount > 500) {
|
if (isRoutstr) {
|
||||||
|
if (!Number.isInteger(amount) || amount < 1) {
|
||||||
|
setTopupError('Amount must be a whole number of sats');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (amount < 1 || amount > 500) {
|
||||||
setTopupError('Amount must be between $1 and $500');
|
setTopupError('Amount must be between $1 and $500');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,15 +166,24 @@ export function ProviderBalance({
|
|||||||
let displayValue = 'N/A';
|
let displayValue = 'N/A';
|
||||||
|
|
||||||
if (typeof balance === 'number') {
|
if (typeof balance === 'number') {
|
||||||
displayValue = `$${balance.toFixed(2)}`;
|
displayValue = isRoutstr
|
||||||
|
? `${balance.toLocaleString()} sats`
|
||||||
|
: `$${balance.toFixed(2)}`;
|
||||||
} else if (balance && typeof balance === 'object') {
|
} else if (balance && typeof balance === 'object') {
|
||||||
const b = balance as Record<string, unknown>;
|
const b = balance as Record<string, unknown>;
|
||||||
if (typeof b.balance === 'number') {
|
if (typeof b.balance === 'number') {
|
||||||
displayValue = `$${b.balance.toFixed(2)}`;
|
displayValue = isRoutstr
|
||||||
|
? `${b.balance.toLocaleString()} sats`
|
||||||
|
: `$${b.balance.toFixed(2)}`;
|
||||||
} else if (typeof b.balance === 'string') {
|
} else if (typeof b.balance === 'string') {
|
||||||
displayValue = b.balance;
|
displayValue = b.balance;
|
||||||
} else if (b.amount !== undefined) {
|
} else if (b.amount !== undefined) {
|
||||||
displayValue = `$${Number(b.amount).toFixed(2)}`;
|
const amount = Number(b.amount);
|
||||||
|
if (!Number.isNaN(amount)) {
|
||||||
|
displayValue = isRoutstr
|
||||||
|
? `${amount.toLocaleString()} sats`
|
||||||
|
: `$${amount.toFixed(2)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +215,9 @@ export function ProviderBalance({
|
|||||||
? 'Your account balance has been updated.'
|
? 'Your account balance has been updated.'
|
||||||
: invoiceData
|
: invoiceData
|
||||||
? 'Scan the QR code or copy the Lightning invoice to pay.'
|
? 'Scan the QR code or copy the Lightning invoice to pay.'
|
||||||
: 'Enter the amount you want to add to your account balance.'}
|
: isRoutstr
|
||||||
|
? `Top up your balance on node ${nodeUrl || ''}`.trim()
|
||||||
|
: 'Enter the amount you want to add to your account balance.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -254,20 +278,29 @@ export function ProviderBalance({
|
|||||||
) : (
|
) : (
|
||||||
<div className='grid gap-4 py-4'>
|
<div className='grid gap-4 py-4'>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label htmlFor='topup_amount'>Amount (USD)</Label>
|
<Label htmlFor='topup_amount'>
|
||||||
|
{isRoutstr ? 'Amount (sats)' : 'Amount (USD)'}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id='topup_amount'
|
id='topup_amount'
|
||||||
type='number'
|
type='number'
|
||||||
placeholder='Enter amount (1-500)'
|
placeholder={
|
||||||
|
isRoutstr ? 'Enter amount in sats' : 'Enter amount (1-500)'
|
||||||
|
}
|
||||||
value={topupAmount}
|
value={topupAmount}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTopupAmount(e.target.value);
|
setTopupAmount(e.target.value);
|
||||||
setTopupError('');
|
setTopupError('');
|
||||||
}}
|
}}
|
||||||
min='1'
|
min='1'
|
||||||
max='500'
|
max={isRoutstr ? undefined : '500'}
|
||||||
step='0.01'
|
step={isRoutstr ? '1' : '0.01'}
|
||||||
/>
|
/>
|
||||||
|
{isRoutstr && (
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
The invoice amount will be created in sats.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{topupError && (
|
{topupError && (
|
||||||
<p className='text-destructive text-sm'>{topupError}</p>
|
<p className='text-destructive text-sm'>{topupError}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
Key,
|
Key,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ProviderBalance } from '@/components/provider-balance';
|
import { ProviderBalance } from '@/components/provider-balance';
|
||||||
import { ProviderModelsPanel } from '@/components/provider-models-panel';
|
import { ProviderModelsPanel } from '@/components/provider-models-panel';
|
||||||
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
||||||
|
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -70,10 +74,29 @@ export function ProviderCard({
|
|||||||
onDeleteModel,
|
onDeleteModel,
|
||||||
onOverrideModel,
|
onOverrideModel,
|
||||||
onUpdateApiKey,
|
onUpdateApiKey,
|
||||||
availableMints,
|
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
||||||
const hasDetails = Boolean(provider.api_version) || isExpanded;
|
const hasDetails = Boolean(provider.api_version) || isExpanded;
|
||||||
|
const isRoutstr = provider.provider_type === 'routstr';
|
||||||
|
|
||||||
|
const refundMutation = useMutation({
|
||||||
|
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.ok) {
|
||||||
|
toast.success('Refund successful', { description: data.message });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['provider-balance', provider.id],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['balances'] });
|
||||||
|
} else {
|
||||||
|
toast.error('Refund failed', { description: data.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(`Refund error: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -107,11 +130,13 @@ export function ProviderCard({
|
|||||||
<ProviderBalance
|
<ProviderBalance
|
||||||
providerId={provider.id}
|
providerId={provider.id}
|
||||||
platformUrl={platformUrl}
|
platformUrl={platformUrl}
|
||||||
|
isRoutstr={provider.provider_type === 'routstr'}
|
||||||
|
nodeUrl={provider.base_url}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{provider.provider_type === 'routstr' && (
|
{isRoutstr && (
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -128,6 +153,25 @@ export function ProviderCard({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isRoutstr && provider.api_key && (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => refundMutation.mutate()}
|
||||||
|
disabled={refundMutation.isPending}
|
||||||
|
className='justify-center gap-1.5 text-orange-600 hover:text-orange-700 dark:text-orange-400'
|
||||||
|
title='Refund balance to local wallet'
|
||||||
|
>
|
||||||
|
<RotateCcw
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4',
|
||||||
|
refundMutation.isPending && 'animate-spin'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>Refund</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -171,16 +215,41 @@ export function ProviderCard({
|
|||||||
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
|
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
|
||||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
|
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Generate New Key</DialogTitle>
|
<DialogTitle>
|
||||||
|
{provider.api_key
|
||||||
|
? 'Create New Key on Upstream Node'
|
||||||
|
: 'Create API Key'}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Create a new API key for provider{' '}
|
{provider.api_key
|
||||||
<span className='font-medium'>{provider.provider_type}</span>.
|
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
|
||||||
|
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='py-4'>
|
<div className='py-4'>
|
||||||
<RoutstrCreateKeySection
|
<RoutstrCreateKeySection
|
||||||
baseUrl={provider.base_url || ''}
|
baseUrl={provider.base_url || ''}
|
||||||
onApiKeyCreated={(newApiKey) => {
|
onApiKeyCreated={async (newApiKey) => {
|
||||||
|
if (provider.api_key) {
|
||||||
|
try {
|
||||||
|
const result = await RoutstrProviderService.refundBalance(
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success('Old key refunded', {
|
||||||
|
description: result.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.warning('Refund skipped', {
|
||||||
|
description: result.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.warning(
|
||||||
|
`Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
onUpdateApiKey(newApiKey);
|
onUpdateApiKey(newApiKey);
|
||||||
setIsKeyModalOpen(false);
|
setIsKeyModalOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user