Compare commits

...

30 Commits

Author SHA1 Message Date
red
9ce91f58a9 fxied the bug by calculating the same way as _calculate_usd_max_costs in models.py 2026-02-16 09:18:12 +00: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
1af39f043f fix tests 2026-01-31 07:37:39 +08:00
Shroominic
1751cd3b47 remove deprecated htmx endpoints 2026-01-31 07:23:58 +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
27 changed files with 1581 additions and 2415 deletions

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

@@ -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"],

File diff suppressed because it is too large Load Diff

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

@@ -169,9 +169,32 @@ async def calculate_discounted_max_cost(
tol = settings.tolerance_percentage
tol_factor = max(0.0, 1 - float(tol) / 100.0)
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
if model_obj:
prompt_token_limit: int | None = None
if model_obj.top_provider and (
model_obj.top_provider.context_length
or model_obj.top_provider.max_completion_tokens
):
cl = model_obj.top_provider.context_length
mct = model_obj.top_provider.max_completion_tokens
if cl and mct:
prompt_token_limit = max(0, cl - mct)
elif cl:
prompt_token_limit = cl
elif mct:
prompt_token_limit = 0
elif model_obj.context_length:
prompt_token_limit = model_obj.context_length
if prompt_token_limit is not None:
max_prompt_allowed_sats = (
prompt_token_limit * model_pricing.prompt * tol_factor
)
adjusted = max_cost_for_model
if messages := body.get("messages"):

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

@@ -267,10 +267,9 @@ async def test_admin_endpoint_unauthenticated(
"""Test GET /admin/ endpoint redirects to /"""
await db_snapshot.capture()
response = await integration_client.get("/admin/")
response = await integration_client.get("/admin/api/settings")
assert response.status_code == 307
assert response.headers.get("location") == "/"
assert response.status_code == 403
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["added"]) == 0

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

@@ -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

@@ -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;
}
}
}