Compare commits

..
Author SHA1 Message Date
Shroominic 41fd2e2dfc fix typing 2025-12-24 12:12:32 +01:00
Shroominic 2c404c66d6 ruff fix 2025-12-24 12:10:51 +01:00
Shroominic 0fa3e77f9a more examples for devs and testing 2025-12-24 12:09:50 +01:00
shroominicandGitHub 9594e9fb52 Merge pull request #251 from Routstr/embeddings-integration
Embeddings integration
2025-12-23 20:53:26 +01:00
Shroominic dd4ed7541f undo 43e9732 2025-12-22 11:31:19 +01:00
Shroominic cc42534a97 manual alias due to openrouter api bug 2025-12-22 11:30:51 +01:00
shroominicandGitHub d203370f01 Merge pull request #265 from Routstr/update-cost-calculation
use cost field if available
2025-12-22 10:20:35 +01:00
Shroominic e39742c429 rm completion_image pricing with manual overrides 2025-12-22 09:35:23 +01:00
Shroominic 301dd81215 include openrouters upstream_inference_cost + manually add gemini image completion cost 2025-12-21 09:55:25 +01:00
9qeklajc 5416cefd87 use cost field if available 2025-12-20 16:16:23 +01:00
shroominicandGitHub 8edc3512c1 Merge pull request #258 from Routstr/remove-flaky-wallet-tests
Remove flaky wallet tests
2025-12-19 10:54:26 +01:00
9qeklajc 43e97326e0 fix model naming issue in response 2025-12-12 20:14:43 +01:00
9qeklajc 06770a0702 Merge branch 'v0.2.1' into embeddings-integration 2025-12-12 19:44:45 +01:00
Shroominic 590fb4bc2c ruff fix 2025-12-11 14:13:41 +08:00
Shroominic 5db9abc3ce remove flaky wallet tests 2025-12-11 14:12:34 +08:00
shroominicandGitHub c11cc107c8 Merge pull request #248 from Routstr/feature/dynamic-settings
feat(ui): make admin settings dynamic based on backend response
2025-12-11 14:04:49 +08:00
Shroominic 547365894d add simple test 2025-12-11 13:58:57 +08:00
shroominicandGitHub 19b5f2889a Merge pull request #249 from Routstr/refactor-remove-unused-functions
refactor: rm unused functions
2025-12-11 13:30:59 +08:00
Shroominic c0176a5274 Merge branch 'v0.2.1' into embeddings-integration 2025-12-11 13:06:20 +08:00
Shroominic 329d22363f Merge branch 'v0.2.1' into embeddings-integration 2025-12-11 12:59:41 +08:00
9qeklajc 9438bc957f clean up 2025-12-03 22:24:43 +01:00
9qeklajc 5d2219880d embedding integration 2025-12-03 22:23:03 +01:00
9qeklajc 195da0c9da embedding integration 2025-12-03 22:22:57 +01:00
Shroominic 6b4b3924a1 feat(ui): make admin settings dynamic based on backend response
This update changes the admin settings page to dynamically render settings fields based on the JSON response from the backend, rather than hardcoding them. This ensures that new settings added to the backend are automatically available in the UI without code changes.

- Specific handling for known fields like name, description, urls, keys, mints, and relays remains to provide a polished UX.
- All other fields are rendered dynamically based on their type (boolean, number, string, array).
- Sensitive fields (keys, passwords) are automatically masked.
- Specific internal/unused fields are ignored.
2025-12-02 15:23:00 +08:00
24 changed files with 679 additions and 302 deletions
-37
View File
@@ -1,37 +0,0 @@
import os
import openai
client = openai.OpenAI(
api_key=os.environ["CASHU_TOKEN"],
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
# client=httpx.AsyncClient(
# proxies={"http": "socks5://localhost:9050"},
# ), # to use onion proxy (tor)
)
history: list = []
def chat() -> None:
while True:
user_msg = {"role": "user", "content": input("\nYou: ")}
history.append(user_msg)
ai_msg = {"role": "assistant", "content": ""}
for chunk in client.chat.completions.create(
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
messages=history,
stream=True,
):
if len(chunk.choices) > 0:
content = chunk.choices[0].delta.content
if content is not None:
ai_msg["content"] += content
print(content, end="", flush=True)
print()
history.append(ai_msg)
if __name__ == "__main__":
chat()
+11
View File
@@ -0,0 +1,11 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token,
# cashu token is hashed on the server and acts as an Temporary API key
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
print(resp.json())
+15
View File
@@ -0,0 +1,15 @@
import os
import httpx
# Send a Cashu token to the /create endpoint to get a persistent API key
token = os.environ.get("TOKEN")
if not token:
print("Please set TOKEN environment variable with a Cashu token")
exit(1)
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
print(resp.json())
+12
View File
@@ -0,0 +1,12 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
print("Refund successful!")
print(resp.json())
+16
View File
@@ -0,0 +1,16 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
# The Cashu token to top up with
cashu_token = input("Enter Cashu token to top up: ")
resp = httpx.post(
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
)
print(resp.json())
+15
View File
@@ -0,0 +1,15 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.chat.completions.create(
model=os.environ.get("MODEL", "gpt-5-nano"),
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
+19
View File
@@ -0,0 +1,19 @@
import os
import httpx
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN", ""),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
for model in client.models.list():
print(model.id)
# OR
models = httpx.get(
f"{client.base_url}/v1/models",
headers={"Authorization": f"Bearer {client.api_key}"},
).json()
+31
View File
@@ -0,0 +1,31 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
conversation = [] # type: ignore
# First turn
response1 = client.responses.create( # type: ignore
model="o4-mini",
input="Hi, my name is Alice.",
conversation=conversation,
)
print("Response 1:", response1.output)
# Note: The 'conversation' parameter might need to be constructed differently
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
# Assuming the SDK manages or returns a conversation object/ID:
# conversation.append(response1)
# Second turn - demonstrating intent, actual implementation depends on strict API spec
# response2 = client.responses.create(
# model="openai/gpt-4o-mini",
# input="What is my name?",
# conversation=conversation,
# )
# print("Response 2:", response2.output)
+17
View File
@@ -0,0 +1,17 @@
import os
from openai import OpenAI
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
# and the base_url points to a compatible proxy like Routstr.
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.responses.create(
model="gpt-5-mini",
input="Tell me a three sentence bedtime story about a unicorn.",
)
print(response.output)
+20
View File
@@ -0,0 +1,20 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
stream = client.responses.create(
model="claude-4.5-sonnet",
input="Write a short poem about rust.",
stream=True,
)
for event in stream:
# Note: Depending on the SDK version and response structure,
# you might access event.output_delta or similar fields
print(event, end="", flush=True)
print()
+16
View File
@@ -0,0 +1,16 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.responses.create(
model="gpt-5-mini",
input="What is the latest news about AI?",
tools=[{"type": "web_search"}], # type: ignore
)
print(response.output)
+28
View File
@@ -0,0 +1,28 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
messages = []
while True:
messages.append({"role": "user", "content": input("\nYou: ")})
stream = client.chat.completions.create(
model=os.environ.get("MODEL", "gpt-5.1-mini"),
messages=messages, # type: ignore
stream=True,
)
print("AI: ", end="")
response_content = ""
for chunk in stream:
if content := chunk.choices[0].delta.content: # type: ignore
print(content, end="", flush=True)
response_content += content
print()
messages.append({"role": "assistant", "content": response_content})
+20
View File
@@ -0,0 +1,20 @@
import os
import httpx
from openai import OpenAI
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
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"),
)
print(
client.chat.completions.create(
model="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "Hello from Tor!"}],
)
.choices[0]
.message.content
)
+1
View File
@@ -73,6 +73,7 @@ packages = ["routstr"]
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]
exclude = ["examples"]
[tool.mypy]
python_version = "3.11"
+62 -2
View File
@@ -5,6 +5,7 @@ from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.db import AsyncSession
from ..core.settings import settings
from .price import sats_usd_price
logger = get_logger(__name__)
@@ -64,6 +65,56 @@ async def calculate_cost( # todo: can be sync
)
return cost_data
usage_data = response_data["usage"]
usd_cost = 0.0
# Prioritize cost_details.upstream_inference_cost
if "cost_details" in usage_data:
usd_cost = float(
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
)
# Fallback to cost field if upstream_inference_cost is 0
if usd_cost == 0 and "cost" in usage_data:
try:
usd_cost = float(usage_data.get("cost", 0) or 0)
except Exception:
pass
if usd_cost > 0:
try:
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
logger.info(
"Using cost from usage data/details",
extra={
"usd_cost": usd_cost,
"cost_in_sats": cost_in_sats,
"cost_in_msats": cost_in_msats,
"model": response_data.get("model", "unknown"),
},
)
return CostData(
base_msats=-1,
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
total_msats=cost_in_msats,
)
except Exception as e:
logger.warning(
"Error calculating cost from usage data",
extra={
"error": str(e),
"usd_cost": usd_cost,
"model": response_data.get("model", "unknown"),
},
)
# Fall through to token-based calculation
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
@@ -129,10 +180,19 @@ async def calculate_cost( # todo: can be sync
)
return cost_data
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
# added for response api
input_tokens = (
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
)
output_tokens = (
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
+26 -6
View File
@@ -93,12 +93,32 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/models", timeout=30)
response.raise_for_status()
data = response.json()
models_response, embeddings_response = await asyncio.gather(
client.get(f"{base_url}/models", timeout=30),
client.get(f"{base_url}/embeddings/models", timeout=30),
return_exceptions=True,
)
def process_models_response(
response: httpx.Response | BaseException,
) -> list[dict]:
if not isinstance(response, BaseException):
response.raise_for_status()
data = response.json()
return [
model
for model in data.get("data", [])
if ":free" not in model.get("id", "").lower()
]
return []
models_data: list[dict] = []
for model in data.get("data", []):
models_data.extend(process_models_response(models_response))
models_data.extend(process_models_response(embeddings_response))
# Apply source filter and exclusions
filtered_models = []
for model in models_data:
model_id = model.get("id", "")
if source_filter:
@@ -116,9 +136,9 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
if not _has_valid_pricing(model):
continue
models_data.append(model)
filtered_models.append(model)
return models_data
return filtered_models
except Exception as e:
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
return []
+1 -1
View File
@@ -65,7 +65,7 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
def get_model_instance(model_id: str) -> Model | None:
"""Get Model instance by ID from global cache."""
return _model_instances.get(model_id)
return _model_instances.get(model_id.lower())
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
+70 -51
View File
@@ -734,51 +734,53 @@ class BaseUpstreamProvider:
await client.aclose()
return mapped_error
if path.endswith("chat/completions"):
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
logger.debug(
"Chat completion request analysis",
extra={
"client_wants_streaming": client_wants_streaming,
"model": request_data.get("model", "unknown"),
"key_hash": key.hashed_key[:8] + "...",
},
)
except json.JSONDecodeError:
logger.warning(
"Failed to parse request body JSON for streaming detection"
)
if path.endswith("chat/completions") or path.endswith("embeddings"):
if path.endswith("chat/completions"):
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
logger.debug(
"Chat completion request analysis",
extra={
"client_wants_streaming": client_wants_streaming,
"model": request_data.get("model", "unknown"),
"key_hash": key.hashed_key[:8] + "...",
},
)
except json.JSONDecodeError:
logger.warning(
"Failed to parse request body JSON for streaming detection"
)
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
logger.debug(
"Response type analysis",
extra={
"is_streaming": is_streaming,
"client_wants_streaming": client_wants_streaming,
"upstream_is_streaming": upstream_is_streaming,
"content_type": content_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model
logger.debug(
"Response type analysis",
extra={
"is_streaming": is_streaming,
"client_wants_streaming": client_wants_streaming,
"upstream_is_streaming": upstream_is_streaming,
"content_type": content_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
elif response.status_code == 200:
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
# Handle both non-streaming chat completions and embeddings
if response.status_code == 200:
try:
return await self.handle_non_streaming_chat_completion(
response, key, session, max_cost_for_model
@@ -1519,9 +1521,9 @@ class BaseUpstreamProvider:
error_response.headers["X-Cashu"] = refund_token
return error_response
if path.endswith("chat/completions"):
if path.endswith("chat/completions") or path.endswith("embeddings"):
logger.debug(
"Processing chat completion response",
"Processing completion/embeddings response",
extra={"path": path, "amount": amount, "unit": unit},
)
@@ -1770,15 +1772,32 @@ class BaseUpstreamProvider:
async def _fetch_openrouter_models(self) -> list[dict]:
"""Fetch models from OpenRouter API."""
url = "https://openrouter.ai/api/v1/models"
embeddings_url = "https://openrouter.ai/api/v1/embeddings/models"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
models = response.json()
return [
model
for model in models.get("data", [])
if ":free" not in model.get("id", "").lower()
]
models_response, embeddings_response = await asyncio.gather(
client.get(url), client.get(embeddings_url), return_exceptions=True
)
all_models = []
def process_models_response(
response: httpx.Response | BaseException,
) -> list[dict]:
if not isinstance(response, BaseException):
response.raise_for_status()
data = response.json()
return [
model
for model in data.get("data", [])
if ":free" not in model.get("id", "").lower()
]
return []
all_models.extend(process_models_response(models_response))
all_models.extend(process_models_response(embeddings_response))
return all_models
async def _fetch_provider_models(self) -> dict:
"""Fetch models from provider's API."""
+7 -1
View File
@@ -50,7 +50,13 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
models = [Model(**model) for model in models_data] # type: ignore
# manual alias for openai/text-embedding-ada-002 due to openrouter api bug
for model in models:
if model.id == "openai/text-embedding-ada-002":
model.alias_ids = ["text-embedding-ada-002-v2"]
break
return models
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.
+97
View File
@@ -0,0 +1,97 @@
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_embeddings_endpoint(authenticated_client: AsyncClient) -> None:
"""Test the embeddings endpoint proxy functionality"""
test_payload = {
"model": "text-embedding-ada-002",
"input": "The quick brown fox",
}
mock_response_data = {
"object": "list",
"data": [
{"object": "embedding", "embedding": [0.0023, -0.0012, 0.0045], "index": 0}
],
"model": "text-embedding-ada-002",
"usage": {"prompt_tokens": 5, "total_tokens": 5},
}
with patch("httpx.AsyncClient.send") as mock_send:
# Create a proper async generator for iter_bytes
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield json.dumps(mock_response_data).encode()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = json.dumps(mock_response_data)
# Use MagicMock for synchronous .json() method
mock_response.json = MagicMock(return_value=mock_response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
# Make POST request to embeddings endpoint
response = await authenticated_client.post("/v1/embeddings", json=test_payload)
assert response.status_code == 200
response_data = response.json()
assert response_data["object"] == "list"
assert len(response_data["data"]) == 1
assert response_data["data"][0]["object"] == "embedding"
# Verify request was forwarded
mock_send.assert_called_once()
forwarded_request = mock_send.call_args[0][0]
# Verify the path ends with embeddings
# Note: forwarded path might be full URL
assert str(forwarded_request.url).endswith("embeddings")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_case_insensitivity(authenticated_client: AsyncClient) -> None:
"""Test that model lookups are case insensitive"""
# We'll use a mixed-case model ID that should match the lowercase one in the system
# We assume 'gpt-3.5-turbo' is available in the mock env/database
test_payload = {
"model": "GPT-3.5-TURBO",
"messages": [{"role": "user", "content": "Hello"}],
}
with patch("httpx.AsyncClient.send") as mock_send:
mock_response_data = {
"id": "chatcmpl-123",
"object": "chat.completion",
"choices": [{"message": {"content": "Hi"}}],
"usage": {"total_tokens": 10},
}
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield json.dumps(mock_response_data).encode()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = json.dumps(mock_response_data)
mock_response.json = MagicMock(return_value=mock_response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
response = await authenticated_client.post(
"/v1/chat/completions", json=test_payload
)
assert response.status_code == 200
@@ -3,7 +3,6 @@ Integration tests for wallet authentication system including API key generation
Tests POST /v1/wallet/topup endpoint and authorization header validation.
"""
import hashlib
from datetime import datetime, timedelta
from typing import Any
@@ -15,7 +14,6 @@ from routstr.core.db import ApiKey
from .utils import (
CashuTokenGenerator,
ConcurrencyTester,
ResponseValidator,
)
@@ -389,69 +387,6 @@ async def test_api_key_with_expiry_time(
# The expiry time and refund address functionality is tested elsewhere
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_token_submissions(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test concurrent submissions of different tokens"""
# Generate multiple unique tokens with known amounts
num_tokens = 10
tokens = []
expected_balances = {}
for i in range(num_tokens):
amount = 100 + i * 10
token = await testmint_wallet.mint_tokens(amount)
tokens.append(token)
# Store expected balance by token hash
hashed_key = hashlib.sha256(token.encode()).hexdigest()
expected_balances[hashed_key] = amount * 1000 # msats
# Create concurrent requests
requests = [
{
"method": "GET",
"url": "/v1/wallet/info",
"headers": {"Authorization": f"Bearer {token}"},
}
for token in tokens
]
# Execute concurrently
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=5
)
# All should succeed
assert len(responses) == num_tokens
api_keys = set()
for response in responses:
assert response.status_code == 200
data = response.json()
api_key = data["api_key"]
api_keys.add(api_key)
# Verify balance matches the expected amount
hashed_key = api_key[3:] # Remove "sk-" prefix
assert data["balance"] == expected_balances[hashed_key]
# Should have created unique API keys
assert len(api_keys) == num_tokens
# Verify all keys exist in database
for api_key in api_keys:
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
assert db_key.balance == expected_balances[hashed_key]
@pytest.mark.integration
@pytest.mark.asyncio
async def test_authorization_with_cashu_token_directly(
@@ -504,48 +439,6 @@ async def test_x_cashu_header_support(
assert response.status_code == 200
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.slow
async def test_api_key_consistency_under_load(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test API key generation consistency under concurrent load"""
# Generate a single token
token = await testmint_wallet.mint_tokens(1000)
# First request to create the API key
integration_client.headers["Authorization"] = f"Bearer {token}"
initial_response = await integration_client.get("/v1/wallet/info")
assert initial_response.status_code == 200
expected_api_key = initial_response.json()["api_key"]
expected_balance = initial_response.json()["balance"]
# Try to use the same token concurrently multiple times
# All should return the same API key since it's already created
requests = [
{
"method": "GET",
"url": "/v1/wallet/info",
"headers": {"Authorization": f"Bearer {token}"},
}
for _ in range(20) # 20 concurrent attempts
]
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=10
)
# All should succeed and return the same API key
for response in responses:
assert response.status_code == 200
data = response.json()
assert data["api_key"] == expected_api_key
assert data["balance"] == expected_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_database_timestamp_accuracy(
+1 -40
View File
@@ -13,7 +13,7 @@ from sqlmodel import select, update
from routstr.core.db import ApiKey
from .utils import ConcurrencyTester, ResponseValidator
from .utils import ResponseValidator
@pytest.mark.integration
@@ -204,45 +204,6 @@ async def test_expired_api_key_behavior(
assert db_key.refund_address == "test@lightning.address"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_access_same_api_key(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test concurrent access with the same API key"""
# Get the API key from authenticated client
response = await authenticated_client.get("/v1/wallet/")
api_key = response.json()["api_key"]
initial_balance = response.json()["balance"]
# Create multiple concurrent requests
requests = []
for i in range(20):
# Alternate between both endpoints
endpoint = "/v1/wallet/" if i % 2 == 0 else "/v1/wallet/info"
requests.append(
{
"method": "GET",
"url": endpoint,
"headers": {"Authorization": f"Bearer {api_key}"},
}
)
# Execute concurrently
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=10
)
# All should succeed with consistent data
for response in responses:
assert response.status_code == 200
data = response.json()
assert data["api_key"] == api_key
assert data["balance"] == initial_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_info_data_consistency(
-55
View File
@@ -15,7 +15,6 @@ from routstr.core.db import ApiKey
from .utils import (
CashuTokenGenerator,
ConcurrencyTester,
ResponseValidator,
)
@@ -284,60 +283,6 @@ async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
assert response.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_topups_same_api_key( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Test concurrent top-ups to the same API key"""
# Get API key
response = await authenticated_client.get("/v1/wallet/")
api_key = response.json()["api_key"]
initial_balance = response.json()["balance"]
# Generate multiple unique tokens
num_tokens = 10
tokens = []
total_amount = 0
for i in range(num_tokens):
amount = 100 + i * 10 # Different amounts
token = await testmint_wallet.mint_tokens(amount)
tokens.append(token)
total_amount += amount
# Create concurrent top-up requests
requests = [
{
"method": "POST",
"url": "/v1/wallet/topup",
"params": {"cashu_token": token},
"headers": {"Authorization": f"Bearer {api_key}"},
}
for token in tokens
]
# Execute concurrently
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=5
)
# All should succeed
for response in responses:
assert response.status_code == 200
assert "msats" in response.json()
# Verify final balance is correct
final_response = await authenticated_client.get("/v1/wallet/")
final_balance = final_response.json()["balance"]
expected_balance = initial_balance + (total_amount * 1000)
assert final_balance == expected_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]
+194 -2
View File
@@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
interface SettingsData {
name?: string;
@@ -28,9 +29,32 @@ interface SettingsData {
http_url?: string;
onion_url?: string;
cashu_mints?: string[];
relays?: string[];
[key: string]: unknown;
}
const HANDLED_KEYS = [
'name',
'description',
'http_url',
'onion_url',
'npub',
'nsec',
'cashu_mints',
'relays',
'admin_password',
'id',
'updated_at',
];
const IGNORED_KEYS = [
'upstream_base_url',
'upstream_api_key',
'upstream_provider_fee',
'exchange_fee',
'models_path',
];
interface PasswordData {
current_password: string;
new_password: string;
@@ -44,6 +68,7 @@ export function AdminSettings() {
const [error, setError] = useState<string>('');
const [showSecrets, setShowSecrets] = useState(false);
const [newMint, setNewMint] = useState('');
const [newRelay, setNewRelay] = useState('');
const [passwordData, setPasswordData] = useState<PasswordData>({
current_password: '',
new_password: '',
@@ -129,7 +154,7 @@ export function AdminSettings() {
}
};
const handleInputChange = (field: string, value: string | boolean) => {
const handleInputChange = (field: string, value: unknown) => {
setSettings((prev) => ({
...prev,
[field]: value,
@@ -153,6 +178,23 @@ export function AdminSettings() {
}));
};
const addRelay = () => {
if (newRelay.trim()) {
setSettings((prev) => ({
...prev,
relays: [...(prev.relays || []), newRelay.trim()],
}));
setNewRelay('');
}
};
const removeRelay = (index: number) => {
setSettings((prev) => ({
...prev,
relays: prev.relays?.filter((_, i) => i !== index) || [],
}));
};
const renderSecretField = (
field: string,
label: string,
@@ -162,7 +204,7 @@ export function AdminSettings() {
const displayValue = showSecrets ? value : value ? '••••••••' : '';
return (
<div className='space-y-2'>
<div key={field} className='space-y-2'>
<Label htmlFor={field}>{label}</Label>
<div className='flex gap-2'>
<Input
@@ -190,6 +232,89 @@ export function AdminSettings() {
);
};
const renderDynamicField = (key: string, value: unknown) => {
const label = key
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
if (typeof value === 'boolean') {
return (
<div
key={key}
className='flex items-center justify-between space-y-0 py-4'
>
<Label htmlFor={key}>{label}</Label>
<Switch
id={key}
checked={value}
onCheckedChange={(checked) => handleInputChange(key, checked)}
/>
</div>
);
}
if (typeof value === 'number') {
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type='number'
value={value}
onChange={(e) => {
const val = e.target.value === '' ? 0 : Number(e.target.value);
handleInputChange(key, val);
}}
/>
</div>
);
}
if (Array.isArray(value)) {
const strValue = value.join(', ');
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Textarea
id={key}
value={strValue}
onChange={(e) => {
const arr = e.target.value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
handleInputChange(key, arr);
}}
placeholder='Comma separated values'
rows={2}
/>
</div>
);
}
const isSecret =
key.includes('key') ||
key.includes('password') ||
key.includes('secret') ||
key.includes('nsec');
if (isSecret) {
return renderSecretField(key, label);
}
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
value={(value as string) || ''}
onChange={(e) => handleInputChange(key, e.target.value)}
/>
</div>
);
};
if (loading) {
return (
<div className='flex items-center justify-center py-8'>
@@ -337,6 +462,73 @@ export function AdminSettings() {
</CardContent>
</Card>
{/* Relays */}
<Card>
<CardHeader>
<CardTitle>Nostr Relays</CardTitle>
<CardDescription>
Configure Nostr relays for communication
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newRelay'>Add Relay URL</Label>
<div className='flex gap-2'>
<Input
id='newRelay'
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder='wss://relay.example.com'
/>
<Button onClick={addRelay} disabled={!newRelay.trim()}>
Add Relay
</Button>
</div>
</div>
{settings.relays && settings.relays.length > 0 && (
<div className='space-y-2'>
<Label>Configured Relays</Label>
<div className='space-y-2'>
{settings.relays.map((relay, index) => (
<div
key={index}
className='flex items-center gap-2 rounded border p-2'
>
<span className='flex-1 text-sm'>{relay}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeRelay(index)}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Other Settings */}
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
<CardDescription>
Configure additional node settings
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{Object.keys(settings)
.filter(
(key) =>
!HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
)
.map((key) => renderDynamicField(key, settings[key]))}
</CardContent>
</Card>
<Card className='mt-6'>
<CardFooter className='flex justify-between'>
<Button