mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
30 Commits
refactor/r
...
fixed-max-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce91f58a9 | ||
|
|
58fa063c6b | ||
|
|
7c94f60797 | ||
|
|
4cb4c6dfec | ||
|
|
512b686e5f | ||
|
|
f495a10eeb | ||
|
|
d1692edb63 | ||
|
|
8f81bcd2fc | ||
|
|
6a5ed9d063 | ||
|
|
b9890e6ad5 | ||
|
|
e4b8293d41 | ||
|
|
ba5f9fc181 | ||
|
|
795fff61e0 | ||
|
|
f9bfd4f0d2 | ||
|
|
5683382ada | ||
|
|
c92372dafd | ||
|
|
b58dd78fde | ||
|
|
4c31bf9767 | ||
|
|
42efa3c1ba | ||
|
|
74b1d39d5c | ||
|
|
4df4976f44 | ||
|
|
1af39f043f | ||
|
|
1751cd3b47 | ||
|
|
c75f170ed0 | ||
|
|
248937e05f | ||
|
|
31898192b2 | ||
|
|
e50facc835 | ||
|
|
55dc485705 | ||
|
|
80559a57d5 | ||
|
|
8e9f6647e7 |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
282
routstr/auth.py
282
routstr/auth.py
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
4
routstr/nostr/__init__.py
Normal file
4
routstr/nostr/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .discovery import providers_cache_refresher
|
||||
from .listing import announce_provider
|
||||
|
||||
__all__ = ["providers_cache_refresher", "announce_provider"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
142
tests/integration/test_key_logic.py
Normal file
142
tests/integration/test_key_logic.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
144
ui/components/BatchOverrideDialog.tsx
Normal file
144
ui/components/BatchOverrideDialog.tsx
Normal 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 "models" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
72
ui/components/key-options.tsx
Normal file
72
ui/components/key-options.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user