Compare commits

..

33 Commits

Author SHA1 Message Date
9qeklajc
25f427033a add docker file 2026-02-12 21:18:22 +01:00
9qeklajc
d3dd8318e4 update doc 2026-02-12 21:15:44 +01:00
9qeklajc
58fa063c6b Merge pull request #352 from Routstr/fix-pyament-finalization
enforce payment finalization
2026-02-08 23:20:20 +01:00
9qeklajc
7c94f60797 Merge pull request #351 from Routstr/child-key-expiration
Child key expiration
2026-02-08 23:20:10 +01:00
9qeklajc
4cb4c6dfec fix test 2026-02-08 23:06:48 +01:00
9qeklajc
512b686e5f fmt 2026-02-08 22:56:38 +01:00
9qeklajc
f495a10eeb keys with different config and better reset 2026-02-08 22:54:59 +01:00
9qeklajc
d1692edb63 no balance limit for parent key 2026-02-07 00:13:57 +01:00
9qeklajc
8f81bcd2fc fix do not remove key after refund 2026-02-05 18:48:46 +01:00
9qeklajc
6a5ed9d063 fmt 2026-02-05 01:20:10 +01:00
9qeklajc
b9890e6ad5 fmt 2026-02-05 01:19:11 +01:00
9qeklajc
e4b8293d41 lint 2026-02-05 01:03:25 +01:00
9qeklajc
ba5f9fc181 improvve key logic 2026-02-04 23:10:50 +01:00
9qeklajc
795fff61e0 child-key-expiration 2026-02-02 22:29:54 +01:00
shroominic
f9bfd4f0d2 routstr/v0.3.0
v0.3.0
2026-02-02 16:44:31 +08:00
9qeklajc
5683382ada Merge pull request #341 from Routstr/refactor/remove-unused-code
refactor: remove unused code
2026-02-01 23:09:33 +01:00
9qeklajc
c92372dafd Merge pull request #342 from Routstr/refactor/remove-deprecated-admin-html
refactor: remove deprecated admin html
2026-02-01 23:09:06 +01:00
9qeklajc
b58dd78fde Merge pull request #339 from Routstr/refactor/nostr-discovery
refactor: nostr logic
2026-02-01 23:06:09 +01:00
9qeklajc
4c31bf9767 enforce payment finalization 2026-02-01 23:01:47 +01:00
shroominic
42efa3c1ba Merge pull request #337 from Routstr/no-default-next-public-api-url
No default next public api url
2026-01-31 07:42:14 +08:00
shroominic
74b1d39d5c Merge pull request #332 from Routstr/missing-delete-button
Missing delete button
2026-01-31 07:42:04 +08:00
shroominic
4df4976f44 Merge pull request #331 from Routstr/batch-override-models
batch override models
2026-01-31 07:41:54 +08:00
Shroominic
4aa57959bf prettier 2026-01-31 07:38:05 +08:00
Shroominic
b1facd58d5 rm test checking unused functions 2026-01-31 07:21:44 +08:00
Shroominic
6288d6fef7 more unused code lmao 2026-01-31 07:09:04 +08:00
Shroominic
a1223ad610 rm unused code lol 2026-01-31 07:08:54 +08:00
Shroominic
c75f170ed0 Refactor: Move Discovery and Nostr logic to routstr/nostr package 2026-01-31 06:59:27 +08:00
Shroominic
248937e05f comment out NEXT_PUBLIC_API_URL by default 2026-01-30 10:48:37 +08:00
Shroominic
31898192b2 added missing delete button for custom models 2026-01-29 13:05:56 +08:00
Shroominic
e50facc835 prettier 2026-01-29 11:05:46 +08:00
Shroominic
55dc485705 Merge branch 'v0.3.0' into batch-override-models 2026-01-29 11:03:43 +08:00
Shroominic
80559a57d5 fix linting 2026-01-29 10:55:07 +08:00
Shroominic
8e9f6647e7 batch override models 2026-01-29 09:08:26 +08:00
32 changed files with 1644 additions and 549 deletions

50
Dockerfile.full Normal file
View File

@@ -0,0 +1,50 @@
# Multi-stage Dockerfile for Routstr (includes UI build)
# Stage 1: Build the UI
FROM node:23-alpine AS ui-builder
WORKDIR /app/ui
# Install pnpm
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
# Copy UI source
COPY ui/package.json ui/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY ui/ ./
ENV NEXT_TELEMETRY_DISABLED=1
# Next.js build produces a static export in 'out' directory
RUN pnpm run build
# Stage 2: Build the Routstr Node
FROM ghcr.io/astral-sh/uv:python3.11-alpine AS runner
# Install system dependencies
RUN apk add --no-cache \
pkgconf \
build-base \
automake \
autoconf \
libtool \
m4 \
perl \
git
WORKDIR /app
# Copy the rest of the application (required for uv sync to find the package)
COPY . .
# Install dependencies including the specific secp256k1 branch
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
RUN uv sync --no-dev
# Copy the built UI from the ui-builder stage
COPY --from=ui-builder /app/ui/out ./ui_out
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
# Run the application
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]

View File

@@ -6,7 +6,7 @@ services:
context: ./ui
dockerfile: Dockerfile.build
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
volumes:
- ./ui_out:/output

View File

@@ -6,30 +6,25 @@ Production deployment guide for Routstr Provider nodes.
For production, use Docker Compose with persistent storage and optional Tor support.
### Basic Setup
### Unified Setup (All-in-one)
To build and run the node with the UI integrated in a single container using the multi-stage build:
Create a `compose.yml`:
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
container_name: routstr
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
```bash
docker build -f Dockerfile.full -t routstr-full .
docker run -d -p 8000:8000 --env-file .env routstr-full
```
Start the node:
### Advanced Setup (Separated UI & Node)
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
```bash
docker compose up -d
```
Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/).
This will:
1. **Build the UI**: Compiles the frontend and copies it to a shared volume.
2. **Start Routstr**: Runs the Python node, mounting the built UI.
3. **Start Tor**: Provides anonymous access via a `.onion` address.
---
@@ -189,8 +184,20 @@ docker compose up -d
## Building from Source
### Unified Image (UI + Node)
The easiest way to build everything from source into a single production-ready image:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
docker build -t routstr-local .
docker build -f Dockerfile.full -t routstr-full .
```
### Individual Components
If you prefer building them separately or using Docker Compose:
```bash
# Build using compose
docker compose build
# Or build the node only (requires manual UI build first)
docker build -t routstr-node .
```

View File

@@ -26,6 +26,8 @@ You bring the API keys, Routstr handles the billing, payments, and client manage
## 1. Start the Node
You can run the pre-built image directly:
```bash
docker run -d \
--name routstr \
@@ -34,6 +36,16 @@ docker run -d \
ghcr.io/routstr/proxy:latest
```
### Build from Source (Recommended)
If you want to build the node and UI yourself from source, use the unified Dockerfile:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
docker build -f Dockerfile.full -t routstr-local .
docker run -d -p 8000:8000 --name routstr routstr-local
```
Verify it's running:
```bash

View File

@@ -0,0 +1,37 @@
"""add key management and reset fields to api_keys
Revision ID: 06f81c0fc88d
Revises: c2d3e4f5a6b7
Create Date: 2026-02-04 22:44:03.311983
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "06f81c0fc88d"
down_revision = "c2d3e4f5a6b7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("api_keys", sa.Column("balance_limit", sa.Integer(), nullable=True))
op.add_column(
"api_keys",
sa.Column(
"balance_limit_reset", sqlmodel.sql.sqltypes.AutoString(), nullable=True
),
)
op.add_column(
"api_keys", sa.Column("balance_limit_reset_date", sa.Integer(), nullable=True)
)
op.add_column("api_keys", sa.Column("validity_date", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("api_keys", "validity_date")
op.drop_column("api_keys", "balance_limit_reset_date")
op.drop_column("api_keys", "balance_limit_reset")
op.drop_column("api_keys", "balance_limit")

View File

@@ -1,10 +1,14 @@
import asyncio
import hashlib
import math
import random
import time
from datetime import datetime
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, update
from sqlmodel import col, select, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
@@ -24,16 +28,60 @@ logger = get_logger(__name__)
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
"""Checks if a key's balance limit should be reset based on its policy."""
if key.balance_limit is not None and key.balance_limit_reset:
now = int(time.time())
reset_date = key.balance_limit_reset_date or 0
should_reset = False
if key.balance_limit_reset == "daily":
if (
datetime.fromtimestamp(now).date()
> datetime.fromtimestamp(reset_date).date()
):
should_reset = True
elif key.balance_limit_reset == "weekly":
if (
datetime.fromtimestamp(now).isocalendar()[:2]
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
):
should_reset = True
elif key.balance_limit_reset == "monthly":
dt_now = datetime.fromtimestamp(now)
dt_reset = datetime.fromtimestamp(reset_date)
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
should_reset = True
if should_reset:
logger.info(
"Resetting balance limit for key",
extra={
"key_hash": key.hashed_key[:8] + "...",
"policy": key.balance_limit_reset,
"old_spent": key.total_spent,
},
)
key.total_spent = 0
key.balance_limit_reset_date = now
session.add(key)
await session.flush()
return True
return False
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
min_cost: int = 0,
) -> ApiKey:
"""
Validates the provided API key using SQLModel.
If it's a cashu key, it redeems it and stores its hash and balance.
Otherwise checks if the hash of the key exists.
Includes a balance check against min_cost for limited keys.
"""
logger.debug(
"Starting bearer key validation",
@@ -43,6 +91,7 @@ async def validate_bearer_key(
else bearer_key,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
"min_cost": min_cost,
},
)
@@ -97,6 +146,50 @@ async def validate_bearer_key(
},
)
# Check and reset limit if needed
await check_and_reset_limit(existing_key, session)
# Early check: Billing balance check (Parent balance)
billing_key = await get_billing_key(existing_key, session)
if min_cost > 0 and billing_key.total_balance < min_cost:
logger.warning(
"Insufficient billing balance during validation",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"balance": billing_key.total_balance,
"required": min_cost,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {min_cost} mSats required for this model. {billing_key.total_balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
# Early check: Spending limit check (Child key limit)
if (
min_cost > 0
and existing_key.balance_limit is not None
and existing_key.total_spent + existing_key.reserved_balance + min_cost
> existing_key.balance_limit
):
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Balance limit exceeded: {existing_key.balance_limit} mSats limit. {existing_key.total_spent} already spent ({existing_key.reserved_balance} reserved), {min_cost} minimum required for this model.",
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
)
return existing_key
else:
logger.warning(
@@ -152,6 +245,19 @@ async def validate_bearer_key(
},
)
# Early check: Billing balance check
if min_cost > 0 and existing_key.total_balance < min_cost:
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {min_cost} mSats required for this model. {existing_key.total_balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
return existing_key
logger.info(
@@ -311,6 +417,8 @@ async def pay_for_request(
key: ApiKey, cost_per_request: int, session: AsyncSession
) -> int:
"""Process payment for a request."""
# Ensure cost_per_request is at least the minimum allowed request cost
cost_per_request = max(cost_per_request, settings.min_request_msat)
billing_key = await get_billing_key(key, session)
@@ -349,6 +457,57 @@ async def pay_for_request(
},
)
# Check validity date
if key.validity_date is not None:
if time.time() > key.validity_date:
logger.warning(
"Key validity date expired",
extra={
"key_hash": key.hashed_key[:8] + "...",
"validity_date": key.validity_date,
"current_time": time.time(),
},
)
raise HTTPException(
status_code=403,
detail={
"error": {
"message": "API key has expired (validity date reached).",
"type": "invalid_request_error",
"code": "key_expired",
}
},
)
# Check balance limit for child keys (or any key with a limit)
if key.balance_limit is not None:
await check_and_reset_limit(key, session)
if (
key.total_spent + key.reserved_balance + cost_per_request
> key.balance_limit
):
logger.warning(
"Balance limit exceeded",
extra={
"key_hash": key.hashed_key[:8] + "...",
"total_spent": key.total_spent,
"reserved": key.reserved_balance,
"balance_limit": key.balance_limit,
"required": cost_per_request,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
)
logger.debug(
"Charging base cost for request",
extra={
@@ -371,12 +530,15 @@ async def pay_for_request(
)
result = await session.exec(stmt) # type: ignore[call-overload]
# Also increment total_requests on the child key if it's different
# Also increment total_requests and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_requests=col(ApiKey.total_requests) + 1)
.values(
total_requests=col(ApiKey.total_requests) + 1,
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -440,12 +602,15 @@ async def revert_pay_for_request(
result = await session.exec(stmt) # type: ignore[call-overload]
# Also decrement total_requests on the child key if it's different
# Also decrement total_requests and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_requests=col(ApiKey.total_requests) - 1)
.values(
total_requests=col(ApiKey.total_requests) - 1,
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -509,6 +674,19 @@ async def adjust_payment_for_tokens(
)
)
await session.exec(release_stmt) # type: ignore[call-overload]
# Also release on child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost
)
)
await session.exec(child_release_stmt) # type: ignore[call-overload]
await session.commit()
logger.warning(
"Released reservation without charging (fallback)",
@@ -551,12 +729,16 @@ async def adjust_payment_for_tokens(
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + cost.total_msats)
.values(
total_spent=col(ApiKey.total_spent) + cost.total_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -631,12 +813,16 @@ async def adjust_payment_for_tokens(
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -673,12 +859,16 @@ async def adjust_payment_for_tokens(
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -737,12 +927,16 @@ async def adjust_payment_for_tokens(
)
result = await session.exec(refund_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -815,3 +1009,65 @@ async def adjust_payment_for_tokens(
"output_msats": 0,
"total_msats": deducted_max_cost,
}
async def periodic_key_reset() -> None:
"""Background task to reset key limits based on their policy."""
from .core.db import create_session
while True:
try:
interval = 3600 # Run every hour
jitter = 300
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
try:
async with create_session() as session:
# Find all keys that have a reset policy
stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore
keys = (await session.exec(stmt)).all()
now = int(time.time())
updated_count = 0
for key in keys:
reset_date = key.balance_limit_reset_date or 0
should_reset = False
if key.balance_limit_reset == "daily":
if (
datetime.fromtimestamp(now).date()
> datetime.fromtimestamp(reset_date).date()
):
should_reset = True
elif key.balance_limit_reset == "weekly":
if (
datetime.fromtimestamp(now).isocalendar()[:2]
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
):
should_reset = True
elif key.balance_limit_reset == "monthly":
dt_now = datetime.fromtimestamp(now)
dt_reset = datetime.fromtimestamp(reset_date)
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
should_reset = True
if should_reset:
key.total_spent = 0
key.balance_limit_reset_date = now
session.add(key)
updated_count += 1
if updated_count > 0:
await session.commit()
logger.info(
"Periodic key reset complete",
extra={"keys_reset": updated_count},
)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in periodic_key_reset: {e}")

View File

@@ -1,5 +1,6 @@
import asyncio
import hashlib
import time
from time import monotonic
from typing import Annotated, NoReturn
@@ -44,6 +45,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
"total_requests": key.total_requests,
"total_spent": key.total_spent,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
@@ -70,9 +74,24 @@ async def account_info(
@router.get("/create")
async def create_balance(
initial_balance_token: str, session: AsyncSession = Depends(get_session)
initial_balance_token: str,
balance_limit: int | None = None,
balance_limit_reset: str | None = None,
validity_date: int | None = None,
session: AsyncSession = Depends(get_session),
) -> dict:
key = await validate_bearer_key(initial_balance_token, session)
if balance_limit is not None or balance_limit_reset or validity_date:
key.balance_limit = balance_limit
key.balance_limit_reset = balance_limit_reset
key.validity_date = validity_date
if balance_limit_reset:
key.balance_limit_reset_date = int(time.time())
session.add(key)
await session.commit()
await session.refresh(key)
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
@@ -167,11 +186,11 @@ async def refund_wallet_endpoint(
bearer_value: str = authorization[7:]
key: ApiKey = await validate_bearer_key(bearer_value, session)
if cached := await _refund_cache_get(bearer_value):
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
if key.parent_key_hash:
raise HTTPException(
status_code=400,
@@ -232,7 +251,9 @@ async def refund_wallet_endpoint(
await _refund_cache_set(bearer_value, result)
await session.delete(key)
key.balance = 0
key.reserved_balance = 0
session.add(key)
await session.commit()
return result
@@ -253,6 +274,9 @@ async def donate(token: str, ref: str | None = None) -> str:
class ChildKeyRequest(BaseModel):
count: int
balance_limit: int | None = None
balance_limit_reset: str | None = None
validity_date: int | None = None
@router.post("/child-key")
@@ -302,6 +326,12 @@ async def create_child_key(
hashed_key=new_key_hash,
balance=0,
parent_key_hash=key.hashed_key,
balance_limit=payload.balance_limit,
balance_limit_reset=payload.balance_limit_reset,
balance_limit_reset_date=int(time.time())
if payload.balance_limit_reset
else None,
validity_date=payload.validity_date,
)
session.add(child_key)
new_keys.append("sk-" + new_key_hash)
@@ -320,6 +350,39 @@ async def create_child_key(
return response_data
class ChildKeyResetRequest(BaseModel):
child_key: str
@router.post("/child-key/reset")
async def reset_child_key_spent(
payload: ChildKeyResetRequest,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Resets the total_spent of a child key. Must be called by the parent."""
child_key_raw = payload.child_key
if child_key_raw.startswith("sk-"):
child_key_raw = child_key_raw[3:]
child_key = await session.get(ApiKey, child_key_raw)
if not child_key:
raise HTTPException(status_code=404, detail="Child key not found.")
if child_key.parent_key_hash != key.hashed_key:
raise HTTPException(
status_code=403, detail="Unauthorized. You are not the parent of this key."
)
child_key.total_spent = 0
if child_key.balance_limit_reset:
child_key.balance_limit_reset_date = int(time.time())
session.add(child_key)
await session.commit()
return {"success": True, "message": "Child key balance reset successfully."}
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],

View File

@@ -55,11 +55,50 @@ async def get_temporary_balances_api(request: Request) -> list[dict[str, object]
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
"parent_key_hash": key.parent_key_hash,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
for key in api_keys
]
class ApiKeyUpdate(BaseModel):
balance_limit: int | None = None
balance_limit_reset: str | None = None
validity_date: int | None = None
@admin_router.patch(
"/api/apikeys/{hashed_key}", dependencies=[Depends(require_admin_api)]
)
async def update_apikey(
request: Request, hashed_key: str, update: ApiKeyUpdate
) -> dict:
async with create_session() as session:
key = await session.get(ApiKey, hashed_key)
if not key:
raise HTTPException(status_code=404, detail="API key not found")
if update.balance_limit is not None:
key.balance_limit = update.balance_limit
if update.balance_limit_reset is not None:
key.balance_limit_reset = update.balance_limit_reset
if update.validity_date is not None:
key.validity_date = update.validity_date
session.add(key)
await session.commit()
await session.refresh(key)
return {
"hashed_key": key.hashed_key,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
@admin_router.get("/api/balances", dependencies=[Depends(require_admin_api)])
async def get_balances_api(request: Request) -> list[dict[str, object]]:
balance_details, _tw, _tu, _ow = await fetch_all_balances()
@@ -402,6 +441,91 @@ async def delete_all_provider_models(provider_id: int) -> dict[str, object]:
return {"ok": True, "deleted": len(rows)}
class BatchOverrideRequest(BaseModel):
models: list[ModelCreate]
@admin_router.post(
"/api/upstream-providers/{provider_id}/batch-override",
dependencies=[Depends(require_admin_api)],
)
async def batch_override_provider_models(
provider_id: int, payload: BatchOverrideRequest
) -> dict[str, object]:
"""Batch override models for a specific provider."""
logger.info(
f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}"
)
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
overridden_count = 0
for model_data in payload.models:
# Try to get existing model regardless of whether it's enabled or not
existing_row = await session.get(ModelRow, (model_data.id, provider_id))
if existing_row:
# Update existing
existing_row.name = model_data.name
existing_row.description = model_data.description
existing_row.created = int(model_data.created)
existing_row.context_length = int(model_data.context_length)
existing_row.architecture = json.dumps(model_data.architecture)
existing_row.pricing = json.dumps(model_data.pricing)
existing_row.sats_pricing = None
existing_row.per_request_limits = (
json.dumps(model_data.per_request_limits)
if model_data.per_request_limits is not None
else None
)
existing_row.top_provider = (
json.dumps(model_data.top_provider) if model_data.top_provider else None
)
existing_row.canonical_slug = model_data.canonical_slug
existing_row.alias_ids = (
json.dumps(model_data.alias_ids) if model_data.alias_ids else None
)
existing_row.enabled = model_data.enabled
session.add(existing_row)
else:
# Create new
row = ModelRow(
id=model_data.id,
name=model_data.name,
description=model_data.description,
created=int(model_data.created),
context_length=int(model_data.context_length),
architecture=json.dumps(model_data.architecture),
pricing=json.dumps(model_data.pricing),
sats_pricing=None,
per_request_limits=(
json.dumps(model_data.per_request_limits)
if model_data.per_request_limits is not None
else None
),
top_provider=(
json.dumps(model_data.top_provider) if model_data.top_provider else None
),
canonical_slug=model_data.canonical_slug,
alias_ids=(
json.dumps(model_data.alias_ids) if model_data.alias_ids else None
),
upstream_provider_id=provider_id,
enabled=model_data.enabled,
)
session.add(row)
overridden_count += 1
await session.commit()
await refresh_model_maps()
return {"ok": True, "count": overridden_count, "message": f"Successfully batch overridden {overridden_count} models"}
class UpstreamProviderCreate(BaseModel):
provider_type: str
base_url: str
@@ -446,14 +570,12 @@ async def create_upstream_provider(
async with create_session() as session:
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == payload.base_url,
UpstreamProviderRow.api_key == payload.api_key,
UpstreamProviderRow.base_url == payload.base_url
)
)
if result.first():
raise HTTPException(
status_code=409,
detail="Provider with this base URL and API key already exists",
status_code=409, detail="Provider with this base URL already exists"
)
provider = UpstreamProviderRow(

View File

@@ -51,6 +51,22 @@ class ApiKey(SQLModel, table=True): # type: ignore
parent_key_hash: str | None = Field(
default=None, foreign_key="api_keys.hashed_key", index=True
)
balance_limit: int | None = Field(
default=None,
description="Max spendable balance in msats for this key (mostly for child keys)",
)
balance_limit_reset: str | None = Field(
default=None,
description="Reset policy for balance limit (manual, daily, monthly, etc.)",
)
balance_limit_reset_date: int | None = Field(
default=None,
description="Unix timestamp of the last time the balance limit was reset",
)
validity_date: int | None = Field(
default=None,
description="Unix timestamp after which the key is no longer valid",
)
@property
def total_balance(self) -> int:
@@ -113,7 +129,9 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
__tablename__ = "upstream_providers"
__table_args__ = (
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
UniqueConstraint(
"base_url", "api_key", name="uq_upstream_providers_base_url_api_key"
),
)
id: int | None = Field(default=None, primary_key=True)
provider_type: str = Field(

View File

@@ -10,9 +10,10 @@ from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException
from ..auth import periodic_key_reset
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..nostr import announce_provider, providers_cache_refresher
from ..nostr.discovery import providers_router
from ..payment.models import models_router, update_sats_pricing
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
@@ -46,6 +47,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
providers_task = None
models_refresh_task = None
model_maps_refresh_task = None
key_reset_task = None
try:
# Run database migrations on startup
@@ -101,6 +103,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
key_reset_task = asyncio.create_task(periodic_key_reset())
yield
@@ -130,6 +133,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
models_refresh_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
if key_reset_task is not None:
key_reset_task.cancel()
try:
tasks_to_wait = []
@@ -147,6 +152,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(models_refresh_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if key_reset_task is not None:
tasks_to_wait.append(key_reset_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)

View File

@@ -143,7 +143,7 @@ def resolve_bootstrap() -> Settings:
pass
if not base.onion_url:
try:
from ..nip91 import discover_onion_url_from_tor # type: ignore
from ..nostr.listing import discover_onion_url_from_tor # type: ignore
discovered = discover_onion_url_from_tor()
if discovered:

View File

@@ -23,6 +23,9 @@ class InvoiceCreateRequest(BaseModel):
api_key: str | None = Field(
default=None, description="Required for topup operations"
)
balance_limit: int | None = Field(default=None)
balance_limit_reset: str | None = Field(default=None)
validity_date: int | None = Field(default=None)
class InvoiceCreateResponse(BaseModel):
@@ -94,6 +97,9 @@ async def create_invoice(
status="pending",
api_key_hash=request.api_key[3:] if request.api_key else None,
purpose=request.purpose,
balance_limit=request.balance_limit,
balance_limit_reset=request.balance_limit_reset,
validity_date=request.validity_date,
expires_at=expires_at,
)

View File

@@ -0,0 +1,4 @@
from .discovery import providers_cache_refresher
from .listing import announce_provider
__all__ = ["providers_cache_refresher", "announce_provider"]

View File

@@ -8,8 +8,8 @@ import httpx
import websockets
from fastapi import APIRouter, HTTPException
from .core.logging import get_logger
from .core.settings import settings
from ..core.logging import get_logger
from ..core.settings import settings
logger = get_logger(__name__)
@@ -72,8 +72,6 @@ async def query_nostr_relay_for_providers(
elif data[0] == "NOTICE":
try:
msg = str(data[1])
if len(msg) > 200:
msg = msg[:200] + "..."
logger.debug(f"Relay notice: {msg}")
except Exception:
logger.debug("Relay notice received")

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
NIP-91: Routstr Provider Discoverability Implementation
Listing: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
@@ -18,15 +18,15 @@ from nostr.key import PrivateKey
from nostr.message_type import ClientMessageType
from nostr.relay_manager import RelayManager
from .core import get_logger
from .core.settings import settings
from ..core import get_logger
from ..core.settings import settings
logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
from .core.main import __version__ as imported_version
from ..core.main import __version__ as imported_version
return imported_version
except Exception:
@@ -71,7 +71,7 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
return None
def create_nip91_event(
def create_listing_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
@@ -80,7 +80,7 @@ def create_nip91_event(
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a NIP-91 compliant provider announcement event (kind:38421).
Create a listing provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
@@ -164,14 +164,14 @@ def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
return True
async def query_nip91_events(
async def query_listing_events(
relay_url: str,
pubkey: str,
provider_id: str | None = None,
timeout: int = 30,
) -> tuple[list[dict[str, Any]], bool]:
"""
Query a Nostr relay for NIP-91 provider announcements (kind:38421) via nostr library.
Query a Nostr relay for listing provider announcements (kind:38421) via nostr library.
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
succeeded without transport-level errors.
@@ -188,7 +188,7 @@ async def query_nip91_events(
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
filters = Filters([flt])
sub_id = f"nip91_{int(time.time())}"
sub_id = f"routstr_listing_{int(time.time())}"
rm.add_subscription(sub_id, filters)
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
req.extend(filters.to_json_array())
@@ -294,7 +294,7 @@ async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) ->
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
try:
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
events, _ok = await query_listing_events(relay_url, public_key_hex, None)
return events
except Exception:
return []
@@ -330,7 +330,7 @@ async def publish_to_relay(
timeout: int = 30,
) -> bool:
"""
Publish a NIP-91 event to a nostr relay via nostr library.
Publish a listing event to a nostr relay via nostr library.
"""
def _sync_publish() -> bool:
@@ -341,7 +341,7 @@ async def publish_to_relay(
time.sleep(1.0)
# Publish the event as-is via publish_message to preserve signature
rm.publish_message(json.dumps(["EVENT", event]))
logger.debug(f"Sent NIP-91 event {event.get('id', '')} to {relay_url}")
logger.debug(f"Sent listing event {event.get('id', '')} to {relay_url}")
time.sleep(1.0)
return True
except Exception as e:
@@ -364,13 +364,13 @@ async def announce_provider() -> None:
# Check for NSEC in environment (use NSEC only)
nsec = settings.nsec
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
logger.info("Nostr private key not found (NSEC), skipping listing announcement")
return
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
logger.error("Failed to parse NSEC, skipping listing announcement")
return
private_key_hex, public_key_hex = keypair
@@ -409,7 +409,7 @@ async def announce_provider() -> None:
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping listing publish."
)
return
@@ -434,7 +434,7 @@ async def announce_provider() -> None:
# Create the candidate event that we would publish
version_str = get_app_version()
candidate_event = create_nip91_event(
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
@@ -474,7 +474,7 @@ async def announce_provider() -> None:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
events, ok = await query_listing_events(relay_url, public_key_hex, provider_id)
if ok:
_register_success(relay_url)
existing_events.extend(events)
@@ -489,7 +489,7 @@ async def announce_provider() -> None:
if not all_match:
logger.debug(
"No matching NIP-91 announcement found or differences detected; publishing update"
"No matching listing announcement found or differences detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
@@ -502,11 +502,11 @@ async def announce_provider() -> None:
else:
_register_failure(relay_url)
logger.info(
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
f"Published listing announcement to {success_count}/{len(relay_urls)} relays"
)
else:
logger.debug(
"Matching NIP-91 announcement already present; skipping publish on startup"
"Matching listing announcement already present; skipping publish on startup"
)
# Re-announce periodically (every 24 hours)
@@ -518,7 +518,7 @@ async def announce_provider() -> None:
# Build fresh candidate event for comparison
version_str = get_app_version()
candidate_event = create_nip91_event(
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
@@ -533,7 +533,7 @@ async def announce_provider() -> None:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(
events, ok = await query_listing_events(
relay_url, public_key_hex, provider_id
)
if ok:
@@ -549,7 +549,7 @@ async def announce_provider() -> None:
if all_match:
logger.debug(
"Matching NIP-91 announcement already present; skipping periodic re-announce"
"Matching listing announcement already present; skipping periodic re-announce"
)
continue
@@ -567,8 +567,8 @@ async def announce_provider() -> None:
_register_failure(relay_url)
except asyncio.CancelledError:
logger.info("NIP-91 announcement task cancelled")
logger.info("Listing announcement task cancelled")
break
except Exception as e:
logger.debug(f"Error in NIP-91 announcement loop: {type(e).__name__}")
logger.debug(f"Error in listing announcement loop: {type(e).__name__}")
# Continue running despite errors

View File

@@ -25,82 +25,6 @@ class LNURLError(Exception):
"""LNURL related errors."""
def parse_lightning_invoice_amount(invoice: str, currency: str = "sat") -> int:
"""Parse Lightning invoice (BOLT-11) to extract amount in specified currency units.
Args:
invoice: BOLT-11 Lightning invoice string
currency: Target currency unit ("sat" or "msat")
Returns:
Amount in the specified currency unit
Raises:
LNURLError: If invoice format is invalid or amount cannot be parsed
"""
invoice = invoice.lower().strip()
if not invoice.startswith("ln"):
raise LNURLError("Invalid Lightning invoice format")
# Find the network part (bc, tb, etc.)
network_start = 2
while network_start < len(invoice) and invoice[network_start] not in "0123456789":
network_start += 1
if network_start >= len(invoice):
raise LNURLError("Invalid Lightning invoice format")
# Parse amount and multiplier
amount_str = ""
multiplier = ""
i = network_start
# Extract numeric part
while i < len(invoice) and invoice[i].isdigit():
amount_str += invoice[i]
i += 1
# Extract multiplier if present
if i < len(invoice) and invoice[i] in "munp":
multiplier = invoice[i]
i += 1
# Check if we have the required "1" separator
if i >= len(invoice) or invoice[i] != "1":
raise LNURLError("Invalid Lightning invoice format")
if not amount_str:
raise LNURLError("Lightning invoice amount not specified")
# Convert to base units
try:
amount = int(amount_str)
except ValueError:
raise LNURLError("Invalid Lightning invoice amount")
# Apply multiplier to get millisatoshis
if multiplier == "m": # milli = 10^-3
amount_msat = amount * 100_000_000 # amount is in BTC * 10^-3
elif multiplier == "u": # micro = 10^-6
amount_msat = amount * 100_000 # amount is in BTC * 10^-6
elif multiplier == "n": # nano = 10^-9
amount_msat = amount * 100 # amount is in BTC * 10^-9
elif multiplier == "p": # pico = 10^-12
amount_msat = amount // 10 # amount is in BTC * 10^-12
else:
# No multiplier means the amount is in BTC
amount_msat = amount * 100_000_000_000 # Convert BTC to msat
# Convert to target currency unit
if currency == "msat":
return amount_msat
elif currency == "sat":
return amount_msat // 1000
else:
raise LNURLError(f"Unsupported currency for Lightning: {currency}")
async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL.

View File

@@ -143,14 +143,6 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
return []
def is_openrouter_upstream() -> bool:
try:
base = (settings.upstream_base_url or "").strip().rstrip("/")
except Exception:
return False
return base.lower() == "https://openrouter.ai/api/v1"
def _row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
@@ -203,29 +195,6 @@ def _row_to_model(
return model
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
return {
"id": model.id,
"name": model.name,
"created": model.created,
"description": model.description,
"context_length": model.context_length,
"architecture": json.dumps(model.architecture.dict()),
"pricing": json.dumps(model.pricing.dict()),
"sats_pricing": json.dumps(model.sats_pricing.dict())
if model.sats_pricing
else None,
"per_request_limits": json.dumps(model.per_request_limits)
if model.per_request_limits is not None
else None,
"top_provider": json.dumps(model.top_provider.dict())
if model.top_provider is not None
else None,
"enabled": model.enabled,
"upstream_provider_id": model.upstream_provider_id,
}
async def list_models(
session: AsyncSession,
upstream_id: int,
@@ -262,21 +231,6 @@ async def list_models(
]
async def get_model_by_id(
model_id: str, provider_id: int, session: AsyncSession
) -> Model | None:
from ..core.db import UpstreamProviderRow
row = await session.get(ModelRow, (model_id, provider_id))
if not row or not row.enabled:
return None
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider or not provider.enabled:
return None
provider_fee = provider.provider_fee if provider else 1.01
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
"""Calculate max costs in USD based on model context/token limits.

View File

@@ -17,6 +17,7 @@ from .core.db import (
get_session,
)
from .core.exceptions import UpstreamError
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
check_token_balance,
@@ -176,6 +177,9 @@ async def proxy(
max_cost_for_model = await calculate_discounted_max_cost(
_max_cost_for_model, request_body_dict, model_obj=model_obj
)
# Ensure max_cost_for_model is at least the minimum allowed request cost
max_cost_for_model = max(max_cost_for_model, settings.min_request_msat)
check_token_balance(headers, request_body_dict, max_cost_for_model)
if x_cashu := headers.get("x-cashu", None):
@@ -206,7 +210,9 @@ async def proxy(
)
elif auth := headers.get("authorization", None):
key = await get_bearer_token_key(headers, path, session, auth)
key = await get_bearer_token_key(
headers, path, session, auth, max_cost_for_model
)
else:
if request.method not in ["GET"]:
@@ -381,7 +387,7 @@ async def proxy(
async def get_bearer_token_key(
headers: dict, path: str, session: AsyncSession, auth: str
headers: dict, path: str, session: AsyncSession, auth: str, min_cost: int = 0
) -> ApiKey:
"""Handle bearer token authentication proxy requests."""
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
@@ -397,6 +403,7 @@ async def get_bearer_token_key(
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"min_cost": min_cost,
},
)
@@ -435,6 +442,7 @@ async def get_bearer_token_key(
session,
refund_address,
key_expiry_time, # type: ignore
min_cost=min_cost,
)
logger.info(
"Bearer token validated successfully",

View File

@@ -427,7 +427,11 @@ class BaseUpstreamProvider:
)
async def handle_streaming_chat_completion(
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int
self,
response: httpx.Response,
key: ApiKey,
max_cost_for_model: int,
background_tasks: BackgroundTasks,
) -> StreamingResponse:
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
@@ -534,7 +538,14 @@ class BaseUpstreamProvider:
}
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception:
except Exception as e:
logger.exception(
"Error during usage finalization",
extra={
"key_hash": key.hashed_key[:8] + "...",
"error": str(e),
},
)
# Fallback: yield original usage chunk if adjustment fails
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
@@ -555,7 +566,9 @@ class BaseUpstreamProvider:
raise
finally:
if not usage_finalized:
await finalize_db_only()
# Create a background task to ensure finalization happens
# even if the generator is closed early
background_tasks.add_task(finalize_db_only)
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -1136,12 +1149,12 @@ class BaseUpstreamProvider:
)
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 = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model, background_tasks
)
result.background = background_tasks
return result

View File

@@ -0,0 +1,142 @@
import time
from datetime import datetime, timedelta
import pytest
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
from routstr.core.db import ApiKey
@pytest.mark.asyncio
async def test_key_validity_date(integration_session: AsyncSession) -> None:
# 1. Create a key that is expired
expired_time = int(time.time()) - 3600
key = ApiKey(hashed_key="expired_key", balance=1000, validity_date=expired_time)
integration_session.add(key)
await integration_session.commit()
# 2. Try to pay for a request - should fail
with pytest.raises(Exception) as excinfo:
await pay_for_request(key, 100, integration_session)
assert "expired" in str(excinfo.value).lower()
@pytest.mark.asyncio
async def test_key_balance_limit(integration_session: AsyncSession) -> None:
# 1. Create a key with a balance limit
key = ApiKey(
hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450
)
integration_session.add(key)
await integration_session.commit()
# 2. Try to pay for a request that exceeds the limit
with pytest.raises(Exception) as excinfo:
await pay_for_request(key, 100, integration_session)
assert "limit exceeded" in str(excinfo.value).lower()
# 3. Try to pay for a request that fits
await pay_for_request(key, 50, integration_session)
await integration_session.refresh(key)
# Note: total_spent is updated in adjust_payment_for_tokens,
# but pay_for_request checks it.
# In our current logic, pay_for_request checks (total_spent + cost) > balance_limit.
@pytest.mark.asyncio
async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None:
# 1. Create a key with a daily reset policy and old reset date
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
key = ApiKey(
hashed_key="daily_reset_key",
balance=10000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=900,
)
integration_session.add(key)
await integration_session.commit()
# 2. Pay for a request - should trigger reset first because it's a new day
# Request is 200, total_spent is 900. 900+200 > 1000,
# but reset should happen making total_spent 0, then 0+200 < 1000.
await pay_for_request(key, 200, integration_session)
await integration_session.refresh(key)
assert key.total_spent == 0 # Reset in pay_for_request happens before charging
# Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests,
# but total_spent is updated in adjust_payment_for_tokens.
# However, the reset logic sets total_spent to 0.
assert key.balance_limit_reset_date is not None
assert key.balance_limit_reset_date > yesterday
@pytest.mark.asyncio
async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None:
# 1. Create multiple keys needing reset
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
key1 = ApiKey(
hashed_key="job_reset_key_1",
balance=1000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=500,
)
key2 = ApiKey(
hashed_key="job_reset_key_2",
balance=1000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=800,
)
integration_session.add(key1)
integration_session.add(key2)
await integration_session.commit()
# 2. Run the periodic reset logic manually (mocking the background task loop)
# We can't easily run the actual loop because it has a sleep,
# but we can test the logic inside.
# Implementation of periodic_key_reset logic for testing:
stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) # noqa: E711
keys = (await integration_session.exec(stmt)).all()
now = int(time.time())
for k in keys:
if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]:
k.total_spent = 0
k.balance_limit_reset_date = now
integration_session.add(k)
await integration_session.commit()
# 3. Verify resets
await integration_session.refresh(key1)
await integration_session.refresh(key2)
assert key1.total_spent == 0
assert key2.total_spent == 0
@pytest.mark.asyncio
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
# This requires mocking the router call or testing the logic in balance.py
from routstr.balance import ApiKey
key = ApiKey(hashed_key="refund_test_key", balance=1000, reserved_balance=100)
integration_session.add(key)
await integration_session.commit()
# Logic from refund_wallet_endpoint:
key.balance = 0
key.reserved_balance = 0
integration_session.add(key)
await integration_session.commit()
# Verify key still exists
fetched_key = await integration_session.get(ApiKey, "refund_test_key")
assert fetched_key is not None
assert fetched_key.balance == 0
assert fetched_key.reserved_balance == 0

View File

@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.discovery import _PROVIDERS_CACHE
from routstr.nostr.discovery import _PROVIDERS_CACHE
from .utils import ResponseValidator
@@ -71,9 +71,10 @@ async def test_providers_endpoint_default_response(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
# Configure mock to return appropriate responses
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
@@ -135,9 +136,10 @@ async def test_providers_endpoint_with_include_json(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {
"status_code": 200,
"json": mock_provider_response,
@@ -209,9 +211,10 @@ async def test_providers_data_structure_validation(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = mock_health_response
response = await integration_client.get("/v1/providers/?include_json=true")
@@ -256,7 +259,8 @@ async def test_providers_endpoint_no_providers_found(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
response = await integration_client.get("/v1/providers/")
@@ -317,10 +321,11 @@ async def test_providers_endpoint_offline_providers(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch(
"routstr.discovery.fetch_provider_health",
"routstr.nostr.discovery.fetch_provider_health",
side_effect=mock_fetch_provider_health,
):
response = await integration_client.get("/v1/providers/?include_json=true")
@@ -386,9 +391,10 @@ async def test_providers_endpoint_duplicate_urls(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {
"status_code": 200,
"endpoint": "root",
@@ -425,7 +431,8 @@ async def test_providers_endpoint_nostr_relay_failures(
raise Exception("Connection to relay failed")
with patch(
"routstr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
"routstr.nostr.discovery.query_nostr_relay_for_providers",
side_effect=failing_query,
):
response = await integration_client.get("/v1/providers/")
@@ -463,9 +470,10 @@ async def test_providers_endpoint_malformed_urls(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
response = await integration_client.get("/v1/providers/")
@@ -495,9 +503,10 @@ async def test_providers_endpoint_response_format(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test default format
@@ -545,9 +554,10 @@ async def test_providers_endpoint_concurrent_requests(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Create concurrent requests
@@ -587,9 +597,10 @@ async def test_providers_endpoint_parameter_validation(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test various parameter values
@@ -639,9 +650,10 @@ async def test_no_database_changes_during_provider_operations(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Make multiple requests with different parameters

View File

@@ -65,9 +65,10 @@ async def test_full_balance_refund_returns_cashu_token(
except Exception as e:
pytest.fail(f"Invalid Cashu token format: {e}")
# Try to use the API key - should fail since it's been deleted
# Try to use the API key - should still work but have 0 balance
response = await authenticated_client.get("/v1/wallet/")
assert response.status_code == 401
assert response.status_code == 200
assert response.json()["balance"] == 0
# The refund token has been validated above by decoding it
# The API key deletion has been verified by the 401 response
@@ -261,17 +262,16 @@ async def test_database_state_after_refund(
response = await authenticated_client.post("/v1/wallet/refund")
assert response.status_code == 200
# Verify key is deleted after refund
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
assert result.scalar_one_or_none() is None
# Refresh the key to get the updated balance from the database
await integration_session.refresh(key_before)
# Count total keys to ensure only the specific one was deleted
# Verify key balance is 0 after refund
assert key_before.balance == 0
# Count total keys to ensure it wasn't deleted
result = await integration_session.execute(select(ApiKey))
remaining_keys = result.scalars().all()
# Should have no keys left (assuming clean test environment)
assert len(remaining_keys) == 0
assert len(remaining_keys) == 1
@pytest.mark.integration
@@ -388,9 +388,10 @@ async def test_refund_during_active_usage(
# Refund should succeed
assert refund_response.status_code == 200
# Further usage should fail
# Further usage should return 200 but with 0 balance
response = await authenticated_client.get("/v1/wallet/")
assert response.status_code == 401
assert response.status_code == 200
assert response.json()["balance"] == 0
@pytest.mark.integration
@@ -535,6 +536,3 @@ async def test_refund_with_expired_key(
# Should still allow manual refund
assert response.status_code == 200
assert response.json()["recipient"] == "expired@ln.address"

View File

@@ -1,155 +0,0 @@
"""Unit tests for model row payload conversion.
This module tests that _model_to_row_payload correctly serializes model data
for database storage. Pricing is stored as-is without fee application.
Fees are now applied per-provider when reading from the database.
Key behaviors tested:
1. Pricing is stored as-is without fee application
2. All model fields are correctly serialized to JSON
3. Optional fields are handled correctly (None values)
4. Pricing structure is preserved
5. Original model objects are not mutated
"""
import json
import os
import pytest
# Set required env vars before importing
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.payment.models import ( # noqa: E402
Architecture,
Model,
Pricing,
_model_to_row_payload,
)
@pytest.fixture
def base_architecture() -> Architecture:
"""Provide standard architecture for test models."""
return Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="gpt",
instruct_type="chat",
)
@pytest.fixture
def standard_pricing() -> Pricing:
"""Provide standard USD pricing with known values for testing."""
return Pricing(
prompt=0.001,
completion=0.002,
request=0.01,
image=0.05,
web_search=0.03,
internal_reasoning=0.015,
max_prompt_cost=10.0,
max_completion_cost=20.0,
max_cost=30.0,
)
@pytest.fixture
def standard_model(base_architecture: Architecture, standard_pricing: Pricing) -> Model:
"""Create a standard test model with known pricing."""
return Model(
id="test-model-standard",
name="Test Model Standard",
created=1234567890,
description="A standard test model",
context_length=8192,
architecture=base_architecture,
pricing=standard_pricing,
)
def test_pricing_stored_without_fees(standard_model: Model) -> None:
"""Verify pricing is stored as-is without any fee application."""
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
assert pricing["prompt"] == pytest.approx(0.001, rel=1e-9)
assert pricing["completion"] == pytest.approx(0.002, rel=1e-9)
assert pricing["request"] == pytest.approx(0.01, rel=1e-9)
assert pricing["image"] == pytest.approx(0.05, rel=1e-9)
assert pricing["web_search"] == pytest.approx(0.03, rel=1e-9)
assert pricing["internal_reasoning"] == pytest.approx(0.015, rel=1e-9)
assert pricing["max_prompt_cost"] == pytest.approx(10.0, rel=1e-9)
assert pricing["max_completion_cost"] == pytest.approx(20.0, rel=1e-9)
assert pricing["max_cost"] == pytest.approx(30.0, rel=1e-9)
def test_zero_value_pricing_fields(base_architecture: Architecture) -> None:
"""Verify that zero-value pricing fields are stored correctly."""
zero_pricing = Pricing(
prompt=0.0,
completion=0.0,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
max_prompt_cost=0.0,
max_completion_cost=0.0,
max_cost=0.0,
)
model = Model(
id="test-model-zero",
name="Test Model Zero",
created=1234567890,
description="A model with zero pricing",
context_length=8192,
architecture=base_architecture,
pricing=zero_pricing,
)
payload = _model_to_row_payload(model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
assert pricing["prompt"] == pytest.approx(0.0, rel=1e-9)
assert pricing["completion"] == pytest.approx(0.0, rel=1e-9)
assert pricing["request"] == pytest.approx(0.0, rel=1e-9)
def test_payload_structure_unchanged(standard_model: Model) -> None:
"""Verify that payload structure matches expectations."""
payload = _model_to_row_payload(standard_model)
assert "id" in payload
assert "name" in payload
assert "created" in payload
assert "description" in payload
assert "context_length" in payload
assert "architecture" in payload
assert "pricing" in payload
assert "sats_pricing" in payload
assert "per_request_limits" in payload
assert "top_provider" in payload
assert "enabled" in payload
assert "upstream_provider_id" in payload
assert isinstance(payload["architecture"], str)
assert isinstance(payload["pricing"], str)
def test_original_model_not_mutated(standard_model: Model) -> None:
"""Verify that the original model object is not mutated."""
original_prompt = standard_model.pricing.prompt
original_completion = standard_model.pricing.completion
_model_to_row_payload(standard_model)
assert standard_model.pricing.prompt == original_prompt
assert standard_model.pricing.completion == original_completion

View File

@@ -21,6 +21,7 @@ import {
AdminModel,
} from '@/lib/api/services/admin';
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { BatchOverrideDialog } from '@/components/BatchOverrideDialog';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
@@ -405,6 +406,9 @@ export default function ProvidersPage() {
mode: 'create',
initialData: null,
});
const [batchOverrideProviderId, setBatchOverrideProviderId] = useState<
number | null
>(null);
const [formData, setFormData] = useState<CreateUpstreamProvider>({
provider_type: 'openrouter',
@@ -484,6 +488,25 @@ export default function ProvidersPage() {
},
});
const deleteModelMutation = useMutation({
mutationFn: ({
providerId,
modelId,
}: {
providerId: number;
modelId: string;
}) => AdminService.deleteProviderModel(providerId, modelId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['provider-models', variables.providerId],
});
toast.success('Model deleted successfully');
},
onError: (error: Error) => {
toast.error(`Failed to delete model: ${error.message}`);
},
});
const handleCreateAccount = async () => {
setIsCreatingAccount(true);
try {
@@ -562,6 +585,12 @@ export default function ProvidersPage() {
}
};
const handleDeleteModel = (providerId: number, modelId: string) => {
if (confirm('Are you sure you want to delete this model?')) {
deleteModelMutation.mutate({ providerId, modelId });
}
};
const getDefaultBaseUrl = (type: string) => {
const providerType = providerTypes.find((pt) => pt.id === type);
return providerType?.default_base_url || '';
@@ -629,6 +658,10 @@ export default function ProvidersPage() {
});
};
const handleBatchOverride = (providerId: number) => {
setBatchOverrideProviderId(providerId);
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
@@ -986,16 +1019,28 @@ export default function ProvidersPage() {
provider&apos;s catalog.
</div>
)}
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() =>
handleBatchOverride(provider.id)
}
>
<Database className='mr-2 h-4 w-4' />
Batch Override
</Button>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
</div>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
@@ -1048,6 +1093,22 @@ export default function ProvidersPage() {
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
className='text-destructive hover:text-destructive h-8 w-8'
onClick={() =>
handleDeleteModel(
provider.id,
model.id
)
}
disabled={
deleteModelMutation.isPending
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
))}
@@ -1287,6 +1348,19 @@ export default function ProvidersPage() {
mode={modelDialogState.mode}
/>
)}
{batchOverrideProviderId && (
<BatchOverrideDialog
providerId={batchOverrideProviderId}
isOpen={!!batchOverrideProviderId}
onClose={() => setBatchOverrideProviderId(null)}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-models', batchOverrideProviderId],
});
}}
/>
)}
</SidebarInset>
</SidebarProvider>
);

View File

@@ -0,0 +1,144 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
import { AdminService } from '@/lib/api/services/admin';
import { Loader2, Database } from 'lucide-react';
export interface BatchOverrideDialogProps {
providerId: number;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function BatchOverrideDialog({
providerId,
isOpen,
onClose,
onSuccess,
}: BatchOverrideDialogProps) {
const [jsonInput, setJsonInput] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const sampleJson = {
models: [
{
id: 'model-id-1',
name: 'Model Name 1',
description: 'Description...',
created: Math.floor(Date.now() / 1000),
context_length: 8192,
architecture: {
modality: 'text',
input_modalities: ['text'],
output_modalities: ['text'],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: 0.0,
completion: 0.0,
request: 0.0,
image: 0.0,
web_search: 0.0,
internal_reasoning: 0.0,
},
enabled: true,
},
],
};
const handleBatchOverride = async () => {
if (!jsonInput.trim()) {
toast.error('Please enter JSON content');
return;
}
setIsSubmitting(true);
try {
let data;
try {
data = JSON.parse(jsonInput);
} catch {
throw new Error('Invalid JSON format');
}
if (!data.models || !Array.isArray(data.models)) {
throw new Error('JSON match follow structure: { "models": [...] }');
}
const result = await AdminService.batchOverrideProviderModels(
providerId,
data.models
);
if (result.ok) {
toast.success(result.message || 'Batch override successful');
onSuccess();
onClose();
setJsonInput('');
} else {
throw new Error('Batch override failed');
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Batch override failed';
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='sm:max-w-[800px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Database className='h-4 w-4' />
Batch Override Models
</DialogTitle>
<DialogDescription>
Paste a JSON object with a &quot;models&quot; array containing model
definitions. Existing models with the same ID will be updated.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<Textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder={JSON.stringify(sampleJson, null, 2)}
className='min-h-[400px] font-mono text-xs'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
<Button onClick={handleBatchOverride} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Processing...
</>
) : (
'Batch Override'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -12,8 +12,26 @@ import {
} from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Key, Copy, Check, Loader2 } from 'lucide-react';
import {
Key,
Copy,
Check,
Loader2,
RotateCcw,
Plus,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { KeyOptions } from './key-options';
interface KeyConfig {
id: string;
count: number;
balanceLimit: string;
balanceLimitReset: string;
validityDate: string;
}
interface ChildKeyCreatorProps {
baseUrl?: string;
@@ -30,7 +48,24 @@ export function ChildKeyCreator({
}: ChildKeyCreatorProps) {
const [internalApiKey, setInternalApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [count, setCount] = useState(1);
const [configs, setConfigs] = useState<KeyConfig[]>([
{
id: crypto.randomUUID(),
count: 1,
balanceLimit: '',
balanceLimitReset: '',
validityDate: '',
},
]);
const [childKeyToCheck, setChildKeyToCheck] = useState('');
const [checking, setChecking] = useState(false);
const [keyStatus, setKeyStatus] = useState<{
total_spent: number;
balance_limit: number | null;
validity_date: number | null;
is_expired: boolean;
is_drained: boolean;
} | null>(null);
const [newKeys, setNewKeys] = useState<string[]>([]);
const [resultInfo, setResultInfo] = useState<{
cost_msats: number;
@@ -45,38 +80,72 @@ export function ChildKeyCreator({
onApiKeyChange?.(val);
};
const addConfig = () => {
setConfigs([
...configs,
{
id: crypto.randomUUID(),
count: 1,
balanceLimit: '',
balanceLimitReset: '',
validityDate: '',
},
]);
};
const removeConfig = (id: string) => {
if (configs.length > 1) {
setConfigs(configs.filter((c) => c.id !== id));
}
};
const updateConfig = (id: string, updates: Partial<KeyConfig>) => {
setConfigs(configs.map((c) => (c.id === id ? { ...c, ...updates } : c)));
};
const handleCreateKey = async () => {
if (!activeApiKey && baseUrl) {
toast.error('Please provide a Parent API key first');
return;
}
const requestedCount = Math.max(1, Math.min(50, Number(count)));
setLoading(true);
try {
const result = await WalletService.createChildKey(
baseUrl,
activeApiKey,
requestedCount
);
let allNewKeys: string[] = [];
let totalCost = 0;
let lastParentBalance = 0;
console.log('Created child keys:', result);
for (const config of configs) {
const requestedCount = Math.max(1, Math.min(50, Number(config.count)));
const result = await WalletService.createChildKey(
baseUrl,
activeApiKey,
requestedCount,
config.balanceLimit ? parseInt(config.balanceLimit) : undefined,
config.balanceLimitReset || undefined,
config.validityDate
? Math.floor(
new Date(config.validityDate + 'T23:59:59').getTime() / 1000
)
: undefined
);
if (result.api_keys && result.api_keys.length > 0) {
setNewKeys(result.api_keys);
} else {
throw new Error('No API keys returned from server');
if (result.api_keys) {
allNewKeys = [...allNewKeys, ...result.api_keys];
}
totalCost += result.cost_msats;
lastParentBalance = result.parent_balance;
}
setNewKeys(allNewKeys);
setResultInfo({
cost_msats: result.cost_msats,
parent_balance: result.parent_balance,
cost_msats: totalCost,
parent_balance: lastParentBalance,
});
toast.success(
`${requestedCount} child API key${
requestedCount > 1 ? 's' : ''
`${allNewKeys.length} child API key${
allNewKeys.length > 1 ? 's' : ''
} created successfully`
);
} catch (error) {
@@ -89,6 +158,47 @@ export function ChildKeyCreator({
}
};
const handleCheckKey = async () => {
if (!childKeyToCheck) {
toast.error('Please provide a Child API key to check');
return;
}
setChecking(true);
setKeyStatus(null);
try {
const baseUrlToUse = baseUrl || '';
const response = await fetch(`${baseUrlToUse}/v1/balance/info`, {
headers: {
Authorization: `Bearer ${childKeyToCheck}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch key info');
}
const info = await response.json();
const now = Math.floor(Date.now() / 1000);
setKeyStatus({
total_spent: info.total_spent,
balance_limit: info.balance_limit,
validity_date: info.validity_date,
is_expired: info.validity_date ? now > info.validity_date : false,
is_drained: info.balance_limit
? info.total_spent >= info.balance_limit
: false,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to check child key'
);
} finally {
setChecking(false);
}
};
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
@@ -140,51 +250,116 @@ export function ChildKeyCreator({
</div>
)}
<div className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<div className='flex items-center justify-between'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Number of keys
</label>
<div className='flex flex-col gap-6'>
{configs.map((config, index) => (
<div
key={config.id}
className='bg-muted/30 relative space-y-4 rounded-lg border p-4 pt-6'
>
{configs.length > 1 && (
<Button
variant='ghost'
size='icon'
className='text-destructive hover:bg-destructive/10 hover:text-destructive absolute top-2 right-2 h-7 w-7'
onClick={() => removeConfig(config.id)}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
<div className='flex flex-col gap-4 sm:flex-row sm:items-end'>
<div className='w-full space-y-2 sm:w-32'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Number of keys
</label>
<Input
type='number'
min={1}
max={50}
value={config.count}
onChange={(e) => {
const val = parseInt(e.target.value);
updateConfig(config.id, {
count: isNaN(val)
? 1
: Math.max(1, Math.min(50, val)),
});
}}
className='h-9'
/>
</div>
<div className='flex-1'>
<KeyOptions
balanceLimit={config.balanceLimit}
setBalanceLimit={(val) =>
updateConfig(config.id, { balanceLimit: val })
}
validityDate={config.validityDate}
setValidityDate={(val) =>
updateConfig(config.id, { validityDate: val })
}
balanceLimitReset={config.balanceLimitReset}
setBalanceLimitReset={(val) =>
updateConfig(config.id, { balanceLimitReset: val })
}
/>
</div>
</div>
</div>
))}
<div className='flex justify-center'>
<Button
variant='outline'
size='sm'
onClick={addConfig}
className='gap-2 border-dashed'
>
<Plus className='h-4 w-4' />
Add Another Configuration
</Button>
</div>
<div className='flex flex-wrap items-center justify-between gap-4'>
<div className='text-muted-foreground text-xs'>
{costPerKeyMsats && (
<span className='text-muted-foreground text-[10px]'>
Cost: {costPerKeyMsats * count} mSats
</span>
<p>
Total Cost:{' '}
<span className='text-foreground font-medium'>
{costPerKeyMsats *
configs.reduce(
(acc, c) => acc + Number(c.count),
0
)}{' '}
mSats
</span>
</p>
)}
</div>
<Input
type='number'
min={1}
max={50}
value={count}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val)) {
setCount(Math.max(1, Math.min(50, val)));
} else {
setCount(1);
}
}}
className='w-full sm:w-24'
/>
<Button
onClick={handleCreateKey}
disabled={loading || (!!baseUrl && !activeApiKey)}
className='w-full min-w-[140px] sm:w-auto'
>
{loading ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
<>
<Key className='mr-2 h-4 w-4' />
Generate{' '}
{configs.reduce(
(acc, c) => acc + Number(c.count),
0
)}{' '}
Keys
</>
)}
</Button>
</div>
<Button
onClick={handleCreateKey}
disabled={loading || (!!baseUrl && !activeApiKey)}
className='w-full sm:w-auto'
>
{loading ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
<>
<Key className='mr-2 h-4 w-4' />
Generate {count > 1 ? `${count} Keys` : 'Key'}
</>
)}
</Button>
</div>
<p className='text-muted-foreground text-xs'>
@@ -281,6 +456,91 @@ export function ChildKeyCreator({
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className='text-lg'>Check Child Key Status</CardTitle>
<CardDescription>
View the current spending, limit, and expiration status of any child
key.
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Child API Key
</label>
<Input
value={childKeyToCheck}
onChange={(e) => setChildKeyToCheck(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
</div>
<Button
onClick={handleCheckKey}
disabled={checking || !childKeyToCheck}
variant='outline'
className='w-full'
>
{checking ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Checking...
</>
) : (
<>
<RotateCcw className='mr-2 h-4 w-4' />
Check Status
</>
)}
</Button>
{keyStatus && (
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Total Spent:</span>
<span className='font-mono font-medium'>
{keyStatus.total_spent} mSats
</span>
</div>
{keyStatus.balance_limit !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Limit:</span>
<span className='font-mono font-medium'>
{keyStatus.balance_limit} mSats
</span>
</div>
)}
{keyStatus.validity_date !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Expires:</span>
<span className='font-mono font-medium'>
{new Date(
keyStatus.validity_date * 1000
).toLocaleDateString()}
</span>
</div>
)}
<div className='flex gap-2 pt-2'>
{keyStatus.is_drained && (
<Badge variant='destructive'>Drained</Badge>
)}
{keyStatus.is_expired && (
<Badge variant='destructive'>Expired</Badge>
)}
{!keyStatus.is_drained && !keyStatus.is_expired && (
<Badge className='bg-green-600 hover:bg-green-700'>
Active
</Badge>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { Zap, Calendar, Shield } from 'lucide-react';
import { Input } from '@/components/ui/input';
interface KeyOptionsProps {
balanceLimit: string;
setBalanceLimit: (val: string) => void;
validityDate: string;
setValidityDate: (val: string) => void;
balanceLimitReset: string;
setBalanceLimitReset: (val: string) => void;
showBalanceLimit?: boolean;
}
export function KeyOptions({
balanceLimit,
setBalanceLimit,
validityDate,
setValidityDate,
balanceLimitReset,
setBalanceLimitReset,
showBalanceLimit = true,
}: KeyOptionsProps) {
return (
<div className='grid gap-4 sm:grid-cols-3'>
{showBalanceLimit && (
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Zap className='h-3 w-3' />
Balance Limit (mSats)
</label>
<Input
type='number'
placeholder='No limit'
value={balanceLimit}
onChange={(e) => setBalanceLimit(e.target.value)}
className='h-9 text-xs'
/>
</div>
)}
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Calendar className='h-3 w-3' />
Validity Date
</label>
<Input
type='date'
value={validityDate}
onChange={(e) => setValidityDate(e.target.value)}
className='h-9 text-xs'
/>
</div>
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Shield className='h-3 w-3' />
Reset Policy
</label>
<select
value={balanceLimitReset}
onChange={(e) => setBalanceLimitReset(e.target.value)}
className='bg-background border-input flex h-9 w-full rounded-md border px-3 py-1 text-xs shadow-sm transition-colors'
>
<option value=''>None</option>
<option value='daily'>Daily</option>
<option value='weekly'>Weekly</option>
<option value='monthly'>Monthly</option>
</select>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { KeyOptions } from '@/components/key-options';
type WalletSnapshot = {
apiKey: string;
@@ -86,9 +87,11 @@ export function CashuPaymentWorkflow({
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
const [validityDate, setValidityDate] = useState<string>('');
const activeApiKey = apiKeyInput.trim();
@@ -121,6 +124,15 @@ export function CashuPaymentWorkflow({
const params = new URLSearchParams({
initial_balance_token: initialToken.trim(),
});
if (balanceLimit) params.append('balance_limit', balanceLimit);
if (balanceLimitReset)
params.append('balance_limit_reset', balanceLimitReset);
if (validityDate) {
const timestamp = Math.floor(
new Date(validityDate + 'T23:59:59').getTime() / 1000
);
params.append('validity_date', timestamp.toString());
}
const response = await fetch(
`${baseUrl}/v1/balance/create?${params.toString()}`,
{
@@ -154,7 +166,14 @@ export function CashuPaymentWorkflow({
} finally {
setIsCreatingKey(false);
}
}, [initialToken, baseUrl, onApiKeyCreated]);
}, [
initialToken,
baseUrl,
onApiKeyCreated,
balanceLimit,
balanceLimitReset,
validityDate,
]);
const handleSyncBalance = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
@@ -256,11 +275,10 @@ export function CashuPaymentWorkflow({
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
);
const showCreateDetails =
hasInteractedCreate || initialToken.trim().length > 0;
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
const canTopup = Boolean(activeApiKey);
const showCreateDetails = initialToken.trim().length > 0;
return (
<Card>
@@ -285,12 +303,21 @@ export function CashuPaymentWorkflow({
value={initialToken}
onChange={(event) => setInitialToken(event.target.value)}
placeholder='cashuA1...'
rows={showCreateDetails ? 4 : 2}
rows={4}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='flex flex-wrap gap-2'>
<div className='space-y-4'>
<KeyOptions
balanceLimit={balanceLimit}
setBalanceLimit={setBalanceLimit}
validityDate={validityDate}
setValidityDate={setValidityDate}
balanceLimitReset={balanceLimitReset}
setBalanceLimitReset={setBalanceLimitReset}
showBalanceLimit={false}
/>
<div className='flex flex-wrap items-center gap-3'>
<Button
onClick={handleCreateKey}
disabled={isCreatingKey}
@@ -298,11 +325,13 @@ export function CashuPaymentWorkflow({
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-muted-foreground text-xs'>
<span className='text-muted-foreground text-[0.7rem] leading-relaxed'>
Redeems instantly and returns <code>sk-</code> key.
<br />
Optional limits can be set above for enhanced security.
</span>
</div>
)}
</div>
</section>
<Separator />

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import { KeyOptions } from '@/components/key-options';
type WalletSnapshot = {
apiKey: string;
@@ -89,7 +90,10 @@ export function LightningPaymentWorkflow({
const [isTopupping, setIsTopupping] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
const [validityDate, setValidityDate] = useState<string>('');
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
@@ -166,13 +170,29 @@ export function LightningPaymentWorkflow({
setIsCreating(true);
try {
const payload: {
amount_sats: number;
purpose: string;
balance_limit?: number;
balance_limit_reset?: string;
validity_date?: number;
} = {
amount_sats: amount,
purpose: 'create',
};
if (balanceLimit) payload.balance_limit = parseInt(balanceLimit);
if (balanceLimitReset) payload.balance_limit_reset = balanceLimitReset;
if (validityDate) {
payload.validity_date = Math.floor(
new Date(validityDate + 'T23:59:59').getTime() / 1000
);
}
const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount_sats: amount,
purpose: 'create',
}),
body: JSON.stringify(payload),
});
if (!response.ok) {
@@ -214,7 +234,15 @@ export function LightningPaymentWorkflow({
} finally {
setIsCreating(false);
}
}, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated]);
}, [
createAmount,
baseUrl,
pollInvoiceStatus,
onApiKeyCreated,
balanceLimit,
balanceLimitReset,
validityDate,
]);
const handleTopupInvoice = useCallback(async (): Promise<void> => {
const amount = parseInt(topupAmount);
@@ -333,14 +361,13 @@ export function LightningPaymentWorkflow({
}
}, [recoverInvoice, baseUrl, onApiKeyCreated]);
const showCreateDetails =
hasInteractedCreate || createAmount.trim().length > 0;
const showTopupDetails =
hasInteractedTopup ||
topupAmount.trim().length > 0 ||
topupApiKey.trim().length > 0;
const showRecoverDetails =
hasInteractedRecover || recoverInvoice.trim().length > 0;
const showCreateDetails = createAmount.trim().length > 0;
return (
<Card>
@@ -367,9 +394,18 @@ export function LightningPaymentWorkflow({
onChange={(event) => setCreateAmount(event.target.value)}
placeholder='Amount in sats (e.g., 1000)'
className='text-sm'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='space-y-4'>
<KeyOptions
balanceLimit={balanceLimit}
setBalanceLimit={setBalanceLimit}
validityDate={validityDate}
setValidityDate={setValidityDate}
balanceLimitReset={balanceLimitReset}
setBalanceLimitReset={setBalanceLimitReset}
showBalanceLimit={false}
/>
<div className='space-y-3'>
<Button
onClick={handleCreateInvoice}
@@ -473,7 +509,7 @@ export function LightningPaymentWorkflow({
</div>
)}
</div>
)}
</div>
</section>
<Separator />

View File

@@ -341,6 +341,23 @@ export class AdminService {
};
}
static async batchOverrideProviderModels(
providerId: number,
models: AdminModel[]
): Promise<{ ok: boolean; count: number; message: string }> {
const payload = {
models: models.map((m) => ({
...m,
pricing: this.convertPricingToPerToken(m.pricing),
})),
};
return await apiClient.post<{
ok: boolean;
count: number;
message: string;
}>(`/admin/api/upstream-providers/${providerId}/batch-override`, payload);
}
static async getProviderModel(
providerId: number,
modelId: string

View File

@@ -3,28 +3,6 @@ import { apiClient } from '../client';
import { ConfigurationService } from './configuration';
import axios from 'axios';
export const loginSchema = z.object({
username: z.string().optional(),
password: z.string().optional(),
});
export type LoginRequest = z.infer<typeof loginSchema>;
export const loginResponseSchema = z.object({
id: z.string(),
});
export type LoginResponse = z.infer<typeof loginResponseSchema>;
export async function login(data: LoginRequest): Promise<LoginResponse> {
try {
return await apiClient.post<LoginResponse>('/api/login', data);
} catch (error) {
console.error('Login error:', error);
throw error;
}
}
export const adminLoginSchema = z.object({
password: z.string().min(1, 'Password is required'),
});
@@ -91,40 +69,3 @@ export async function adminLogout(): Promise<void> {
ConfigurationService.clearToken();
}
}
export const registerSchema = z.object({
npub: z.string().min(10, { message: 'must have at least 10 character' }),
name: z.string().optional(),
});
export type RegisterRequest = z.infer<typeof registerSchema>;
export type SchemaRegisterProps = z.infer<typeof registerSchema>;
export const registerResponseSchema = z.object({
user_id: z.string(),
theme: z.string(),
});
export type RegisterResponse = z.infer<typeof registerResponseSchema>;
export async function register(
data: RegisterRequest
): Promise<RegisterResponse> {
try {
return await apiClient.post<RegisterResponse>('/api/register', data);
} catch (error) {
console.error('Registration error:', error);
throw error;
}
}
export const registerUser = register;
export async function getUserSettings(): Promise<{ id: string }> {
try {
return await apiClient.get<{ id: string }>('/api/user/settings');
} catch (error) {
console.error('Error fetching user settings:', error);
throw error;
}
}

View File

@@ -134,7 +134,10 @@ export class WalletService {
static async createChildKey(
baseUrl?: string,
apiKey?: string,
count: number = 1
count: number = 1,
balanceLimit?: number,
balanceLimitReset?: string,
validityDate?: number
): Promise<CreateChildKeyResponse> {
try {
if (baseUrl && apiKey) {
@@ -144,7 +147,12 @@ export class WalletService {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ count }),
body: JSON.stringify({
count,
balance_limit: balanceLimit,
balance_limit_reset: balanceLimitReset,
validity_date: validityDate,
}),
});
if (!response.ok) {
@@ -157,11 +165,49 @@ export class WalletService {
return await apiClient.post<CreateChildKeyResponse>(
'/v1/balance/child-key',
{ count }
{
count,
balance_limit: balanceLimit,
balance_limit_reset: balanceLimitReset,
validity_date: validityDate,
}
);
} catch (error) {
console.error('Error creating child key:', error);
throw error;
}
}
static async resetChildKeySpent(
baseUrl: string | undefined,
parentKey: string,
childKey: string
): Promise<{ success: boolean; message: string }> {
try {
const url = baseUrl
? `${baseUrl}/v1/balance/child-key/reset`
: '/v1/balance/child-key/reset';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${parentKey}`,
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ child_key: childKey }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to reset child key');
}
return await response.json();
} catch (error) {
console.error('Error resetting child key:', error);
throw error;
}
}
}