Compare commits

..
Author SHA1 Message Date
Shroominic ec6a3aadf1 rm sixty nuts mention 2026-01-20 09:46:21 +08:00
Shroominic 9094a1a493 the refactor part 03 2025-12-03 18:30:38 +08:00
Shroominic c9176e5090 fix table header and data column mismatch due to refactor 2025-11-28 20:19:29 +09:00
Shroominic 3d71588fd7 fix tests 2025-11-28 20:09:25 +09:00
Shroominic 21d5e3762b fix tests 2025-11-28 13:54:12 +09:00
Shroominic 7aa035fe37 remove moved file 2025-11-28 13:49:21 +09:00
Shroominic bd3aacd1f5 fix migration 2025-11-28 13:45:32 +09:00
Shroominic 9be8aba417 the refactor part 02 2025-11-28 13:40:35 +09:00
Shroominic fa313e4534 the refactor part 01 2025-11-28 13:40:30 +09:00
104 changed files with 3749 additions and 17561 deletions
+2 -6
View File
@@ -62,18 +62,14 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
node-version: '18'
cache: 'npm'
cache-dependency-path: ui/package-lock.json
- name: Install UI dependencies
working-directory: ./ui
run: npm ci
- name: Run UI format check
working-directory: ./ui
run: npm run format-check
- name: Run UI linting
working-directory: ./ui
run: npm run lint
+2 -2
View File
@@ -34,7 +34,7 @@ sequenceDiagram
- **Model-Based Pricing** Convert USD prices in `models.json` to sats using live BTC/USD rates
- **Admin Dashboard** Simple HTML interface at `/admin/` to view balances and API keys
- **Discovery** Fetch available providers from Nostr relays using NIP-91 protocol
- **NIP-91 Auto-Announcement** Automatically announce this provider to Nostr relays when NSEC is provided
- **Auto-Listing** Automatically announce this provider to Nostr relays when NSEC is provided
- **Docker Support** Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
## Getting Started
@@ -99,7 +99,7 @@ The most common settings are shown below. See `.env.example` for the full list.
- `NPUB` Nostr public key of the proxy
- `HTTP_URL` Public-facing URL of the proxy
- `ONION_URL` Tor hidden service URL of the proxy
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: 'http://127.0.0.1:8000' )
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: '<http://127.0.0.1:8000>' )
## Database Migrations
@@ -0,0 +1,60 @@
"""the refactor
Revision ID: 5f6ac1e4fa9f
Revises: a1a1a1a1a1a1
Create Date: 2025-11-28 13:31:49.796461
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "5f6ac1e4fa9f"
down_revision = "a1a1a1a1a1a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Rename the table
op.rename_table("api_keys", "temporary_credit")
op.add_column(
"temporary_credit", sa.Column("created", sa.DateTime(), nullable=True)
)
op.add_column(
"temporary_credit",
sa.Column("refund_expiration_time", sa.Integer(), nullable=True),
)
op.drop_column("temporary_credit", "key_expiry_time")
op.drop_column("temporary_credit", "total_spent")
op.drop_column("temporary_credit", "total_requests")
def downgrade() -> None:
op.add_column(
"temporary_credit",
sa.Column(
"total_requests",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
)
op.add_column(
"temporary_credit",
sa.Column(
"total_spent",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
)
op.add_column(
"temporary_credit", sa.Column("key_expiry_time", sa.Integer(), nullable=True)
)
op.drop_column("temporary_credit", "refund_expiration_time")
op.drop_column("temporary_credit", "created")
# Revert table rename
op.rename_table("temporary_credit", "api_keys")
@@ -1,39 +0,0 @@
"""Add lightning_invoices table
Revision ID: lightning_invoices
Revises: a1a1a1a1a1a1
Create Date: 2025-12-10 21:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
revision = "lightning_invoices"
down_revision = "a1a1a1a1a1a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"lightning_invoices",
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("bolt11", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("amount_sats", sa.Integer(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("payment_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("api_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.Integer(), nullable=False),
sa.Column("expires_at", sa.Integer(), nullable=False),
sa.Column("paid_at", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("bolt11"),
sa.UniqueConstraint("payment_hash"),
)
def downgrade() -> None:
op.drop_table("lightning_invoices")
-1
View File
@@ -20,7 +20,6 @@ dependencies = [
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
]
[dependency-groups]
+75 -638
View File
@@ -1,661 +1,98 @@
import hashlib
import math
from typing import Optional
from typing import Annotated
from fastapi import HTTPException
from fastapi import Depends, Header, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
from .core.db import AsyncSession, TemporaryCredit, get_session
from .core.settings import settings
from .payment.cost_calculation import (
CostData,
CostDataError,
MaxCostData,
calculate_cost,
)
from .wallet import credit_balance, deserialize_token_from_string
from .payment.wallet import credit_balance, deserialize_token_from_string
logger = get_logger(__name__)
# TODO: implement prepaid api key (not like it was before)
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
async def api_key_to_credit(key: str, db_session: AsyncSession) -> TemporaryCredit:
key = key[3:] if key.startswith("sk-") else key
if existing_credit := await db_session.get(TemporaryCredit, key):
return existing_credit
raise HTTPException(status_code=401, detail="Invalid API-KEY")
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
) -> 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.
"""
logger.debug(
"Starting bearer key validation",
extra={
"key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
},
async def cashu_token_to_credit(
cashu_token: str, db_session: AsyncSession
) -> TemporaryCredit:
try:
token_hash = hashlib.sha256(cashu_token.encode()).hexdigest()
token_obj = deserialize_token_from_string(cashu_token)
except Exception:
raise HTTPException(status_code=400, detail="Invalid token format")
if existing_credit := await db_session.get(TemporaryCredit, token_hash):
return existing_credit
# create new TemporaryCredit
if token_obj.mint in settings.cashu_mints:
refund_currency = token_obj.unit
refund_mint_url = token_obj.mint
else:
refund_mint_url = settings.primary_mint
refund_currency = "sat"
new_credit = TemporaryCredit(
hashed_key=token_hash,
refund_currency=refund_currency,
refund_mint_url=refund_mint_url,
)
db_session.add(new_credit)
if not bearer_key:
logger.error("Empty bearer key provided")
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "API key or Cashu token required",
"type": "invalid_request_error",
"code": "missing_api_key",
}
},
try:
await db_session.flush()
except IntegrityError: # fallback to api key in case of race condition
await db_session.rollback()
return await api_key_to_credit(f"sk-{token_hash}", db_session)
try:
msats = await credit_balance(cashu_token, new_credit, db_session)
except Exception as e:
await db_session.rollback()
logger.error(
"Token redemption failed",
extra={"error": str(e)},
)
raise HTTPException(status_code=400, detail="Token redemption failed")
if bearer_key.startswith("sk-"):
logger.debug(
"Processing sk- prefixed API key",
extra={"key_preview": bearer_key[:10] + "..."},
)
if existing_key := await session.get(ApiKey, bearer_key[3:]):
logger.info(
"Existing sk- API key found",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"balance": existing_key.balance,
"total_requests": existing_key.total_requests,
},
)
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
logger.debug(
"Updated key expiry time",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"expiry_time": key_expiry_time,
},
)
if refund_address is not None:
existing_key.refund_address = refund_address
logger.debug(
"Updated refund address",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"refund_address_preview": refund_address[:20] + "..."
if len(refund_address) > 20
else refund_address,
},
)
return existing_key
else:
logger.warning(
"sk- API key not found in database",
extra={"key_preview": bearer_key[:10] + "..."},
)
if bearer_key.startswith("cashu"):
logger.debug(
"Processing Cashu token",
extra={
"token_preview": bearer_key[:20] + "...",
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
},
)
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
token_obj = deserialize_token_from_string(bearer_key)
logger.debug(
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
)
if existing_key := await session.get(ApiKey, hashed_key):
logger.info(
"Existing Cashu token found",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"balance": existing_key.balance,
"total_requests": existing_key.total_requests,
},
)
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
logger.debug(
"Updated key expiry time for existing Cashu key",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"expiry_time": key_expiry_time,
},
)
if refund_address is not None:
existing_key.refund_address = refund_address
logger.debug(
"Updated refund address for existing Cashu key",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"refund_address_preview": refund_address[:20] + "..."
if len(refund_address) > 20
else refund_address,
},
)
return existing_key
logger.info(
"Creating new Cashu token entry",
extra={
"hash_preview": hashed_key[:16] + "...",
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
},
)
if token_obj.mint in settings.cashu_mints:
refund_currency = token_obj.unit
refund_mint_url = token_obj.mint
else:
refund_currency = "sat"
refund_mint_url = settings.primary_mint
new_key = ApiKey(
hashed_key=hashed_key,
balance=0,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
refund_currency=refund_currency,
refund_mint_url=refund_mint_url,
)
session.add(new_key)
try:
await session.flush()
except IntegrityError:
await session.rollback()
logger.info(
"Concurrent key creation detected, fetching existing key",
extra={"key_hash": hashed_key[:8] + "..."},
)
existing_key = await session.get(ApiKey, hashed_key)
if not existing_key:
raise Exception("Failed to fetch existing key after IntegrityError")
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
if refund_address is not None:
existing_key.refund_address = refund_address
return existing_key
logger.debug(
"New key created, starting token redemption",
extra={"key_hash": hashed_key[:8] + "..."},
)
logger.info(
"AUTH: About to call credit_balance",
extra={"token_preview": bearer_key[:50]},
)
try:
msats = await credit_balance(bearer_key, new_key, session)
logger.info(
"AUTH: credit_balance returned successfully", extra={"msats": msats}
)
except Exception as credit_error:
logger.error(
"AUTH: credit_balance failed",
extra={
"error": str(credit_error),
"error_type": type(credit_error).__name__,
},
)
raise credit_error
if msats <= 0:
logger.error(
"Token redemption returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
)
raise Exception("Token redemption failed")
await session.refresh(new_key)
await session.commit()
logger.info(
"New Cashu token successfully redeemed and stored",
extra={
"key_hash": hashed_key[:8] + "...",
"redeemed_msats": msats,
"final_balance": new_key.balance,
},
)
return new_key
except Exception as e:
logger.error(
"Cashu token redemption failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"token_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"Invalid or expired Cashu key: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
logger.error(
"Invalid API key format",
extra={
"key_preview": bearer_key[:10] + "..."
if len(bearer_key) > 10
else bearer_key,
"key_length": len(bearer_key),
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "Invalid API key",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
async def pay_for_request(
key: ApiKey, cost_per_request: int, session: AsyncSession
) -> int:
"""Process payment for a request."""
await db_session.refresh(new_credit)
await db_session.commit()
logger.info(
"Processing payment for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"current_balance": key.balance,
"required_cost": cost_per_request,
"sufficient_balance": key.balance >= cost_per_request,
},
"New TemporaryCredit created from CashuToken",
extra={"key_hash": token_hash[:8] + "...", "amount_msats": msats},
)
if key.total_balance < cost_per_request:
logger.warning(
"Insufficient balance for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"balance": key.balance,
"reserved_balance": key.reserved_balance,
"required": cost_per_request,
"shortfall": cost_per_request - key.total_balance,
},
)
return new_credit
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
logger.debug(
"Charging base cost for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost": cost_per_request,
"balance_before": key.balance,
},
async def nwc_to_credit(
connection_string: str, db_session: AsyncSession
) -> TemporaryCredit:
raise NotImplementedError
async def get_credit(
authorization: Annotated[str, Header(...)],
db_session: AsyncSession = Depends(get_session),
) -> TemporaryCredit:
authorization = (
authorization[7:] if authorization.startswith("Bearer ") else authorization
)
# Charge the base cost for the request atomically to avoid race conditions
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
total_requests=col(ApiKey.total_requests) + 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Concurrent request depleted balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"required_cost": cost_per_request,
"current_balance": key.balance,
},
)
# Another concurrent request spent the balance first
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
await session.refresh(key)
logger.info(
"Payment processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": cost_per_request,
"new_balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
},
)
return cost_per_request
async def revert_pay_for_request(
key: ApiKey, session: AsyncSession, cost_per_request: int
) -> None:
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
total_requests=col(ApiKey.total_requests) - 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to revert payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_to_revert": cost_per_request,
"current_reserved_balance": key.reserved_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
await session.refresh(key)
async def adjust_payment_for_tokens(
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
) -> dict:
"""
Adjusts the payment based on token usage in the response.
This is called after the initial payment and the upstream request is complete.
Returns cost data to be included in the response.
"""
model = response_data.get("model", "unknown")
logger.debug(
"Starting payment adjustment for tokens",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"deducted_max_cost": deducted_max_cost,
"current_balance": key.balance,
"has_usage": "usage" in response_data,
},
)
match await calculate_cost(response_data, deducted_max_cost, session):
case MaxCostData() as cost:
logger.debug(
"Using max cost data (no token adjustment)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"max_cost": cost.total_msats,
},
)
# Finalize by releasing reservation and charging max cost
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
total_spent=col(ApiKey.total_spent) + cost.total_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"total_cost": cost.total_msats,
"model": model,
},
)
else:
await session.refresh(key)
logger.info(
"Max cost payment finalized",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"new_balance": key.balance,
"model": model,
},
)
return cost.dict()
case CostData() as cost:
# If token-based pricing is enabled and base cost is 0, use token-based cost
# Otherwise, token cost is additional to the base cost
cost_difference = cost.total_msats - deducted_max_cost
total_cost_msats: int = math.ceil(cost.total_msats)
logger.info(
"Calculated token-based cost",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"token_cost": cost.total_msats,
"deducted_max_cost": deducted_max_cost,
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
if cost_difference == 0:
logger.debug(
"Finalizing with exact reserved cost",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
await session.refresh(key)
return cost.dict()
# this should never happen why do we handle this???
if cost_difference > 0:
# Need to charge more than reserved, finalize by releasing reservation and charging total
logger.info(
"Additional charge required for token usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"additional_charge": cost_difference,
"current_balance": key.balance,
"sufficient_balance": key.balance >= cost_difference,
"model": model,
},
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount:
cost.total_msats = total_cost_msats
await session.refresh(key)
logger.info(
"Finalized payment with additional charge",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": total_cost_msats,
"new_balance": key.balance,
"model": model,
},
)
else:
logger.warning(
"Failed to finalize additional charge (concurrent operation)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"attempted_charge": total_cost_msats,
"model": model,
},
)
else:
# Refund some of the base cost
refund = abs(cost_difference)
logger.info(
"Refunding excess payment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refund_amount": refund,
"current_balance": key.balance,
"model": model,
},
)
refund_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
result = await session.exec(refund_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"total_cost": total_cost_msats,
"model": model,
},
)
# Still return the cost data even if we couldn't properly finalize
# The reservation was already made, so the user has paid
cost.total_msats = total_cost_msats
await session.refresh(key)
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
return cost.dict()
case CostDataError() as error:
logger.error(
"Cost calculation error during payment adjustment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
# Fallback return to satisfy type checker; execution should not reach here
return {
"base_msats": deducted_max_cost,
"input_msats": 0,
"output_msats": 0,
"total_msats": deducted_max_cost,
}
if authorization.startswith("cashu"):
return await cashu_token_to_credit(authorization, db_session)
elif authorization.startswith("sk-"):
return await api_key_to_credit(authorization, db_session)
elif authorization.startswith("nwc"):
return await nwc_to_credit(authorization, db_session)
else:
raise HTTPException(status_code=401, detail="Unable to parse bearer token")
+25 -39
View File
@@ -6,12 +6,12 @@ from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from .auth import validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .auth import get_credit
from .core.db import AsyncSession, TemporaryCredit, get_session
from .core.logging import get_logger
from .core.settings import settings
from .lightning import lightning_router
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
from .payment.lnurl import send_to_lnurl
from .payment.wallet import credit_balance, recieve_token, send_token
router = APIRouter()
balance_router = APIRouter(prefix="/v1/balance")
@@ -19,26 +19,15 @@ balance_router = APIRouter(prefix="/v1/balance")
logger = get_logger(__name__)
async def get_key_from_header(
authorization: Annotated[str, Header(...)],
session: AsyncSession = Depends(get_session),
) -> ApiKey:
if authorization.startswith("Bearer "):
return await validate_bearer_key(authorization[7:], session)
raise HTTPException(
status_code=401,
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
# TODO: remove this endpoint when frontend is updated
@router.get("/", include_in_schema=False)
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
async def account_info(
credit: TemporaryCredit = Depends(get_credit),
) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
"reserved": key.reserved_balance,
"api_key": credit.api_key,
"balance": credit.balance,
"reserved": credit.reserved_balance,
}
@@ -58,19 +47,18 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
async def create_balance(
initial_balance_token: str, session: AsyncSession = Depends(get_session)
) -> dict:
key = await validate_bearer_key(initial_balance_token, session)
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
}
credit = await get_credit(initial_balance_token, session)
return {"api_key": credit.api_key, "balance": credit.balance}
@router.get("/info")
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
async def wallet_info(
credit: TemporaryCredit = Depends(get_credit),
) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
"reserved": key.reserved_balance,
"api_key": credit.api_key,
"balance": credit.balance,
"reserved": credit.reserved_balance,
}
@@ -82,7 +70,7 @@ class TopupRequest(BaseModel):
async def topup_wallet_endpoint(
cashu_token: str | None = None,
topup_request: TopupRequest | None = None,
key: ApiKey = Depends(get_key_from_header),
credit: TemporaryCredit = Depends(get_credit),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
if topup_request is not None:
@@ -94,7 +82,7 @@ async def topup_wallet_endpoint(
if len(cashu_token) < 10 or "cashu" not in cashu_token:
raise HTTPException(status_code=400, detail="Invalid token format")
try:
amount_msats = await credit_balance(cashu_token, key, session)
amount_msats = await credit_balance(cashu_token, credit, session)
except ValueError as e:
error_msg = str(e)
if "already spent" in error_msg.lower():
@@ -153,10 +141,11 @@ async def refund_wallet_endpoint(
if cached := await _refund_cache_get(bearer_value):
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
key: TemporaryCredit = await get_credit(bearer_value, session)
remaining_balance_msats: int = key.balance
refund_currency = key.refund_currency or "sat"
if key.refund_currency == "sat":
if refund_currency == "sat":
remaining_balance = remaining_balance_msats // 1000
else:
remaining_balance = remaining_balance_msats
@@ -173,19 +162,18 @@ async def refund_wallet_endpoint(
await send_to_lnurl(
remaining_balance,
key.refund_currency or "sat",
refund_currency,
key.refund_mint_url or global_settings.primary_mint,
key.refund_address,
)
result = {"recipient": key.refund_address}
else:
refund_currency = key.refund_currency or "sat"
token = await send_token(
remaining_balance, refund_currency, key.refund_mint_url
)
result = {"token": token}
if key.refund_currency == "sat":
if refund_currency == "sat":
result["sats"] = str(remaining_balance_msats // 1000)
else:
result["msats"] = str(remaining_balance_msats)
@@ -239,8 +227,6 @@ async def wallet_catch_all(path: str) -> NoReturn:
)
balance_router.include_router(lightning_router)
balance_router.include_router(router)
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
deprecated_wallet_router.include_router(router)
+25 -205
View File
@@ -8,16 +8,16 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from sqlmodel import select
from ..payment.models import _row_to_model, list_models
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..wallet import (
from ..models.crud import row_to_model, list_models
from ..payment.wallet import (
fetch_all_balances,
get_proofs_per_mint_and_unit,
get_wallet,
send_token,
slow_filter_spend_proofs,
)
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
from ..proxy import refresh_model_maps, reinitialize_upstreams
from .db import ModelRow, TemporaryCredit, UpstreamProviderRow, create_session
from .log_manager import log_manager
from .logging import get_logger
from .settings import SettingsService, settings
@@ -107,13 +107,13 @@ async def partial_balances(request: Request) -> str:
@admin_router.get(
"/partials/apikeys",
"/partials/TemporaryCredits",
dependencies=[Depends(require_admin_api)],
response_class=HTMLResponse,
)
async def partial_apikeys(request: Request) -> str:
async def partial_TemporaryCredits(request: Request) -> str:
async with create_session() as session:
result = await session.exec(select(ApiKey))
result = await session.exec(select(TemporaryCredit))
api_keys = result.all()
def fmt_time(ts: int | None) -> str:
@@ -124,7 +124,7 @@ async def partial_apikeys(request: Request) -> str:
rows = "".join(
[
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.refund_address}</td><td>{key.refund_mint_url}</td><td>{key.refund_currency}</td><td>{fmt_time(key.refund_expiration_time)}</td></tr>"
for key in api_keys
]
)
@@ -134,9 +134,9 @@ async def partial_apikeys(request: Request) -> str:
<tr>
<th>Hashed Key</th>
<th>Balance (mSats)</th>
<th>Total Spent (mSats)</th>
<th>Total Requests</th>
<th>Refund Address</th>
<th>Refund Mint</th>
<th>Currency</th>
<th>Refund Time</th>
</tr>
{rows}
@@ -147,17 +147,17 @@ async def partial_apikeys(request: Request) -> str:
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
async def get_temporary_balances_api(request: Request) -> list[dict[str, object]]:
async with create_session() as session:
result = await session.exec(select(ApiKey))
result = await session.exec(select(TemporaryCredit))
api_keys = result.all()
return [
{
"hashed_key": key.hashed_key,
"balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
"refund_mint_url": key.refund_mint_url,
"refund_currency": key.refund_currency,
"refund_expiration_time": key.refund_expiration_time,
}
for key in api_keys
]
@@ -777,8 +777,8 @@ async def dashboard(request: Request) -> str:
<p><em>Save this token! It represents your withdrawn balance.</em></p>
</div>
<div id="apikeys-table"
hx-get="/admin/partials/apikeys"
<div id="TemporaryCredits-table"
hx-get="/admin/partials/TemporaryCredits"
hx-trigger="load"
hx-swap="outerHTML">
<h2>Temporary Balances</h2>
@@ -1763,9 +1763,9 @@ UPSTREAM_PROVIDERS_JS: str = """<!--html-->
provider_fee: feeValue ? parseFloat(feeValue) : defaultFee,
};
const apiKey = document.getElementById('provider-api-key').value;
if (apiKey) {
payload.api_key = apiKey;
const TemporaryCredit = document.getElementById('provider-api-key').value;
if (TemporaryCredit) {
payload.api_key = TemporaryCredit;
}
const saveBtn = document.getElementById('provider-save-btn');
@@ -1783,7 +1783,7 @@ UPSTREAM_PROVIDERS_JS: str = """<!--html-->
body: JSON.stringify(payload)
});
} else {
if (!apiKey) {
if (!TemporaryCredit) {
throw new Error('API Key is required for new providers');
}
resp = await fetch('/admin/api/upstream-providers', {
@@ -2456,7 +2456,7 @@ async def create_provider_model(
await session.refresh(row)
await refresh_model_maps()
return _row_to_model(
return row_to_model(
row, apply_provider_fee=True, provider_fee=provider.provider_fee
).dict() # type: ignore
@@ -2476,7 +2476,7 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
raise HTTPException(
status_code=404, detail="Model not found for this provider"
)
return _row_to_model(
return row_to_model(
row, apply_provider_fee=True, provider_fee=provider.provider_fee
).dict() # type: ignore
@@ -2525,7 +2525,7 @@ async def update_provider_model(
await session.refresh(row)
if was_disabled and payload.enabled:
from ..payment.models import _cleanup_enabled_models_once
from ..models.models import _cleanup_enabled_models_once
try:
await _cleanup_enabled_models_once()
@@ -2536,7 +2536,7 @@ async def update_provider_model(
)
await refresh_model_maps()
return _row_to_model(
return row_to_model(
row, apply_provider_fee=True, provider_fee=provider.provider_fee
).dict() # type: ignore
@@ -2791,192 +2791,12 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
}
class CreateAccountRequest(BaseModel):
provider_type: str
@admin_router.post(
"/api/upstream-providers/create-account",
dependencies=[Depends(require_admin_api)],
)
async def create_provider_account_by_type(
payload: CreateAccountRequest,
) -> dict[str, object]:
"""Create a new account with a provider by provider type (before provider exists in DB)."""
from ..upstream import upstream_provider_classes
provider_class = next(
(
cls
for cls in upstream_provider_classes
if cls.provider_type == payload.provider_type
),
None,
)
if not provider_class:
raise HTTPException(status_code=404, detail="Provider type not found")
try:
account_data = await provider_class.create_account_static()
return {
"ok": True,
"account_data": account_data,
"message": "Account created successfully",
}
except NotImplementedError as e:
raise HTTPException(
status_code=400,
detail=f"Provider does not support account creation: {str(e)}",
)
except Exception as e:
logger.error(
f"Failed to create account for provider type {payload.provider_type}: {e}"
)
raise HTTPException(status_code=500, detail=str(e))
class TopupRequest(BaseModel):
amount: int
@admin_router.post(
"/api/upstream-providers/{provider_id}/topup",
dependencies=[Depends(require_admin_api)],
)
async def initiate_provider_topup(
provider_id: int, payload: TopupRequest
) -> dict[str, object]:
"""Initiate a Lightning Network top-up for the upstream provider account."""
from ..upstream.helpers import _instantiate_provider
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
try:
logger.info(
f"Initiating top-up for provider {provider_id}",
extra={"amount": payload.amount},
)
topup_data = await upstream_instance.initiate_topup(payload.amount)
logger.info(
"Top-up initiated successfully",
extra={
"provider_id": provider_id,
"invoice_id": topup_data.invoice_id,
"amount": topup_data.amount,
},
)
response_data = {
"ok": True,
"topup_data": {
"invoice_id": topup_data.invoice_id,
"payment_request": topup_data.payment_request,
"amount": topup_data.amount,
"currency": topup_data.currency,
"expires_at": topup_data.expires_at,
"checkout_url": topup_data.checkout_url,
},
"message": "Top-up initiated successfully",
}
logger.info("Returning response", extra={"response": response_data})
return response_data
except NotImplementedError as e:
logger.error(f"Provider does not support top-up: {e}")
raise HTTPException(
status_code=400, detail=f"Provider does not support top-up: {str(e)}"
)
except Exception as e:
logger.error(
f"Failed to initiate top-up for provider {provider_id}: {e}",
extra={"error_type": type(e).__name__, "error": str(e)},
)
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get(
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
dependencies=[Depends(require_admin_api)],
)
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
"""Check the status of a Lightning Network top-up invoice."""
from ..upstream.helpers import _instantiate_provider
from ..upstream.ppqai import PPQAIUpstreamProvider
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
if not isinstance(upstream_instance, PPQAIUpstreamProvider):
raise HTTPException(
status_code=400,
detail="Provider does not support top-up status checking",
)
try:
paid = await upstream_instance.check_topup_status(invoice_id)
return {"ok": True, "paid": paid}
except Exception as e:
logger.error(
f"Failed to check top-up status for provider {provider_id}: {e}"
)
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get(
"/api/upstream-providers/{provider_id}/balance",
dependencies=[Depends(require_admin_api)],
)
async def get_provider_balance(provider_id: int) -> dict[str, object]:
"""Get the current account balance for the upstream provider."""
from ..upstream.helpers import _instantiate_provider
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
try:
balance_data = await upstream_instance.get_balance()
return {"ok": True, "balance_data": balance_data}
except NotImplementedError as e:
raise HTTPException(
status_code=400,
detail=f"Provider does not support balance checking: {str(e)}",
)
except Exception as e:
logger.error(f"Failed to fetch balance for provider {provider_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get(
"/api/openrouter-presets",
dependencies=[Depends(require_admin_api)],
)
async def get_openrouter_presets() -> list[dict[str, object]]:
from ..payment.models import async_fetch_openrouter_models
from ..models import async_fetch_openrouter_models
models_data = await async_fetch_openrouter_models()
return models_data
+30 -47
View File
@@ -1,6 +1,6 @@
import os
import time
from contextlib import asynccontextmanager
from datetime import datetime
from typing import AsyncGenerator
from alembic import command
@@ -19,39 +19,40 @@ DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
engine = create_async_engine(DATABASE_URL, echo=False) # echo=True for debugging SQL
class ApiKey(SQLModel, table=True): # type: ignore
__tablename__ = "api_keys"
class TemporaryCredit(SQLModel, table=True): # type: ignore
__tablename__ = "temporary_credit"
hashed_key: str = Field(primary_key=True)
balance: int = Field(default=0, description="Balance in millisatoshis (msats)")
reserved_balance: int = Field(
default=0, description="Reserved balance in millisatoshis (msats)"
)
balance: int = Field(default=0, description="Balance in msats")
reserved_balance: int = Field(default=0, description="Blocked balance in msats")
created: datetime | None = Field(None, description="Timestamp of creation")
refund_address: str | None = Field(
default=None,
description="Lightning address to refund remaining balance after key expires",
None, description="Address to refund on expiration"
)
key_expiry_time: int | None = Field(
default=None,
description="Unix-timestamp after which the cashu-token's balance gets refunded to the refund_address",
)
total_spent: int = Field(
default=0, description="Total spent in millisatoshis (msats)"
)
total_requests: int = Field(default=0)
refund_mint_url: str | None = Field(
default=None,
description="URL of the mint used to create the cashu-token",
default=None, description="Mint used to issue the refund token"
)
refund_currency: str | None = Field(
default=None,
description="Currency of the cashu-token",
refund_currency: str | None = Field(None, description="Currency of the cashu-token")
refund_expiration_time: int | None = Field(
None, description="Refund not allowed after timeout"
)
@property
def total_balance(self) -> int:
def total_balance_msat(self) -> int:
return self.balance - self.reserved_balance
@property
def total_balance_sat(self) -> int:
return self.total_balance_msat // 1000
@property
def total_balance(self) -> int:
return self.total_balance_msat
@property
def api_key(self) -> str:
return "sk-" + self.hashed_key
class ModelRow(SQLModel, table=True): # type: ignore
__tablename__ = "models"
@@ -72,28 +73,6 @@ class ModelRow(SQLModel, table=True): # type: ignore
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
class LightningInvoice(SQLModel, table=True): # type: ignore
__tablename__ = "lightning_invoices"
id: str = Field(primary_key=True, description="Unique invoice identifier")
bolt11: str = Field(description="BOLT11 invoice string", unique=True)
amount_sats: int = Field(description="Amount in satoshis")
description: str = Field(description="Invoice description")
payment_hash: str = Field(description="Payment hash for tracking", unique=True)
status: str = Field(
default="pending", description="pending, paid, expired, cancelled"
)
api_key_hash: str | None = Field(
default=None, description="Associated API key hash for topup operations"
)
purpose: str = Field(description="create or topup")
created_at: int = Field(
default_factory=lambda: int(time.time()), description="Unix timestamp"
)
expires_at: int = Field(description="Unix timestamp when invoice expires")
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
__tablename__ = "upstream_providers"
id: int | None = Field(default=None, primary_key=True)
@@ -118,8 +97,9 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
async def balances_for_mint_and_unit(
db_session: AsyncSession, mint_url: str, unit: str
) -> int:
query = select(func.sum(ApiKey.balance)).where(
ApiKey.refund_mint_url == mint_url, ApiKey.refund_currency == unit
query = select(func.sum(TemporaryCredit.balance)).where(
TemporaryCredit.refund_mint_url == mint_url,
TemporaryCredit.refund_currency == unit,
)
result = await db_session.exec(query)
return result.one() or 0
@@ -149,6 +129,8 @@ def run_migrations() -> None:
import pathlib
try:
logger.info("Starting database migrations")
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"
@@ -165,6 +147,7 @@ def run_migrations() -> None:
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
logger.info("Running migrations to latest revision")
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")
+8 -21
View File
@@ -192,24 +192,21 @@ class SecurityFilter(logging.Filter):
"""Filter out sensitive information from log records."""
try:
message = record.getMessage()
standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
r"nsec[a-z0-9]+", # Nostr Public / Private Key
]
for pattern in standalone_patterns:
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
for key in self.SENSITIVE_KEYS:
if key in message.lower():
key_patterns = [
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
patterns = [
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
]
for pattern in key_patterns:
for pattern in patterns:
message = re.sub(
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
)
record.msg = message
record.args = ()
@@ -338,11 +335,6 @@ def setup_logging() -> None:
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"openai": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"httpcore": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
@@ -365,11 +357,6 @@ def setup_logging() -> None:
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
"alembic": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
},
"root": {
"level": log_level,
+7 -276
View File
@@ -1,33 +1,21 @@
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from starlette.exceptions import HTTPException
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import (
cleanup_enabled_models_periodically,
models_router,
update_sats_pricing,
)
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
from ..wallet import periodic_payout
from ..models.models import models_router
from ..nostr.discovery import providers_router
from ..proxy import proxy_router
from .admin import admin_router
from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .settings import SettingsService
from .settings import settings as global_settings
from .tasks import lifespan
from .ui import setup_ui_routes
# Initialize logging first
setup_logging()
@@ -39,126 +27,6 @@ else:
__version__ = "0.2.1"
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Application startup initiated", extra={"version": __version__})
btc_price_task = None
pricing_task = None
payout_task = None
nip91_task = None
providers_task = None
models_refresh_task = None
models_cleanup_task = None
model_maps_refresh_task = None
try:
# Run database migrations on startup
run_migrations()
# Initialize database connection pools
# This creates any tables that might not be tracked by migrations yet
await init_db()
# Initialize application settings (env -> computed -> DB precedence)
async with create_session() as session:
s = await SettingsService.initialize(session)
# Apply app metadata from settings
try:
app.title = s.name
app.description = s.description
except Exception:
pass
# await ensure_models_bootstrapped()
from ..payment.price import _update_prices
from ..proxy import get_upstreams
from ..upstream.helpers import refresh_upstreams_models_periodically
_update_prices_task = asyncio.create_task(_update_prices())
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
)
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
yield
except asyncio.CancelledError:
# Expected during shutdown
pass
except Exception as e:
logger.error(
"Application startup failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
finally:
logger.info("Application shutdown initiated")
if btc_price_task is not None:
btc_price_task.cancel()
if pricing_task is not None:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
if providers_task is not None:
providers_task.cancel()
if models_refresh_task is not None:
models_refresh_task.cancel()
if models_cleanup_task is not None:
models_cleanup_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
try:
tasks_to_wait = []
if btc_price_task is not None:
tasks_to_wait.append(btc_price_task)
if pricing_task is not None:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
tasks_to_wait.append(models_refresh_task)
if models_cleanup_task is not None:
tasks_to_wait.append(models_cleanup_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
logger.info("Background tasks stopped successfully")
except Exception as e:
logger.error(
"Error stopping background tasks",
extra={"error": str(e), "error_type": type(e).__name__},
)
app = FastAPI(version=__version__, lifespan=lifespan)
@@ -197,144 +65,7 @@ async def providers() -> RedirectResponse:
return RedirectResponse("/v1/providers/")
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
logger.info(f"Serving static UI from {UI_DIST_PATH}")
app.mount(
"/_next",
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
name="next-static",
)
@app.get("/", include_in_schema=False)
async def serve_root_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
# Add explicit route for /index.txt to redirect to /
@app.get("/index.txt", include_in_schema=False)
async def redirect_index_txt() -> RedirectResponse:
return RedirectResponse("/")
@app.get("/admin")
async def admin_redirect() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@app.get("/dashboard", include_in_schema=False)
async def serve_dashboard_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@app.get("/login", include_in_schema=False)
async def serve_login_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "login" / "index.html")
# Add explicit route for /login/index.txt to redirect to /login
@app.get("/login/index.txt", include_in_schema=False)
async def redirect_login_index_txt() -> RedirectResponse:
return RedirectResponse("/login")
@app.get("/model", include_in_schema=False)
async def serve_models_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "model" / "index.html")
# Add explicit route for /model/index.txt to redirect to /model
@app.get("/model/index.txt", include_in_schema=False)
async def redirect_model_index_txt() -> RedirectResponse:
return RedirectResponse("/model")
@app.get("/providers", include_in_schema=False)
async def serve_providers_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
# Add explicit route for /providers/index.txt to redirect to /providers
@app.get("/providers/index.txt", include_in_schema=False)
async def redirect_providers_index_txt() -> RedirectResponse:
return RedirectResponse("/providers")
@app.get("/settings", include_in_schema=False)
async def serve_settings_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
# Add explicit route for /settings/index.txt to redirect to /settings
@app.get("/settings/index.txt", include_in_schema=False)
async def redirect_settings_index_txt() -> RedirectResponse:
return RedirectResponse("/settings")
@app.get("/transactions", include_in_schema=False)
async def serve_transactions_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
# Add explicit route for /transactions/index.txt to redirect to /transactions
@app.get("/transactions/index.txt", include_in_schema=False)
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
@app.get("/balances", include_in_schema=False)
async def serve_balances_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
# Add explicit route for /balances/index.txt to redirect to /balances
@app.get("/balances/index.txt", include_in_schema=False)
async def redirect_balances_index_txt() -> RedirectResponse:
return RedirectResponse("/balances")
@app.get("/logs", include_in_schema=False)
async def serve_logs_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
# Add explicit route for /logs/index.txt to redirect to /logs
@app.get("/logs/index.txt", include_in_schema=False)
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@app.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
# Add explicit route for /usage/index.txt to redirect to /usage
@app.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@app.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
@app.get("/unauthorized/index.txt", include_in_schema=False)
async def redirect_unauthorized_index_txt() -> RedirectResponse:
return RedirectResponse("/unauthorized")
@app.get("/favicon.ico", include_in_schema=False)
async def serve_favicon() -> FileResponse:
icon_path = UI_DIST_PATH / "icon.ico"
if icon_path.exists():
return FileResponse(icon_path)
return FileResponse(UI_DIST_PATH / "favicon.ico")
@app.get("/icon.ico", include_in_schema=False)
async def serve_icon() -> FileResponse:
return FileResponse(UI_DIST_PATH / "icon.ico")
app.mount(
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
)
else:
logger.warning(
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
)
@app.get("/", include_in_schema=False)
async def root_fallback() -> dict:
return {
"name": global_settings.name,
"description": global_settings.description,
"version": __version__,
"status": "running",
"ui": "not available",
}
setup_ui_routes(app)
app.include_router(models_router)
app.include_router(admin_router)
+1 -1
View File
@@ -139,7 +139,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
discovered = discover_onion_url_from_tor()
if discovered:
+132
View File
@@ -0,0 +1,132 @@
import asyncio
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from ..models.models import (
cleanup_enabled_models_periodically,
update_sats_pricing,
)
from ..nostr import announce_provider, providers_cache_refresher
from ..payment.price import update_prices_periodically
from ..payment.wallet import periodic_payout
from ..proxy import get_upstreams, initialize_upstreams, refresh_model_maps_periodically
from ..upstream.helpers import refresh_upstreams_models_periodically
from .db import create_session, init_db, run_migrations
from .logging import get_logger
from .settings import SettingsService
from .settings import settings as global_settings
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Extract version from app if available, or use default/log without it
version = getattr(app, "version", "unknown")
logger.info("Application startup initiated", extra={"version": version})
btc_price_task = None
pricing_task = None
payout_task = None
listing_task = None
providers_task = None
models_refresh_task = None
models_cleanup_task = None
model_maps_refresh_task = None
try:
# Run database migrations on startup
# This ensures the database schema is always up-to-date in production
# Migrations are idempotent - running them multiple times is safe
logger.info("Running database migrations")
run_migrations()
# Initialize database connection pools
# This creates any tables that might not be tracked by migrations yet
await init_db()
# Initialize application settings (env -> computed -> DB precedence)
async with create_session() as session:
s = await SettingsService.initialize(session)
# Apply app metadata from settings
try:
app.title = s.name
app.description = s.description
except Exception:
pass
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
)
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
listing_task = asyncio.create_task(announce_provider())
providers_task = asyncio.create_task(providers_cache_refresher())
await _initialize_upstreams_task
yield
except Exception as e:
logger.error(
"Application startup failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
finally:
logger.info("Application shutdown initiated")
if btc_price_task is not None:
btc_price_task.cancel()
if pricing_task is not None:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if listing_task is not None:
listing_task.cancel()
if providers_task is not None:
providers_task.cancel()
if models_refresh_task is not None:
models_refresh_task.cancel()
if models_cleanup_task is not None:
models_cleanup_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
try:
tasks_to_wait = []
if btc_price_task is not None:
tasks_to_wait.append(btc_price_task)
if pricing_task is not None:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if listing_task is not None:
tasks_to_wait.append(listing_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
tasks_to_wait.append(models_refresh_task)
if models_cleanup_task is not None:
tasks_to_wait.append(models_cleanup_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
logger.info("Background tasks stopped successfully")
except Exception as e:
logger.error(
"Error stopping background tasks",
extra={"error": str(e), "error_type": type(e).__name__},
)
+160
View File
@@ -0,0 +1,160 @@
from pathlib import Path
from fastapi import APIRouter, FastAPI
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from .logging import get_logger
from .settings import settings as global_settings
logger = get_logger(__name__)
def setup_ui_routes(app: FastAPI) -> None:
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
logger.info(f"Serving static UI from {UI_DIST_PATH}")
app.mount(
"/_next",
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
name="next-static",
)
router = APIRouter()
@router.get("/", include_in_schema=False)
async def serve_root_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
# Add explicit route for /index.txt to redirect to /
@router.get("/index.txt", include_in_schema=False)
async def redirect_index_txt() -> RedirectResponse:
return RedirectResponse("/")
@router.get("/admin")
async def admin_redirect() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@router.get("/dashboard", include_in_schema=False)
async def serve_dashboard_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@router.get("/login", include_in_schema=False)
async def serve_login_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "login" / "index.html")
# Add explicit route for /login/index.txt to redirect to /login
@router.get("/login/index.txt", include_in_schema=False)
async def redirect_login_index_txt() -> RedirectResponse:
return RedirectResponse("/login")
@router.get("/model", include_in_schema=False)
async def serve_models_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "model" / "index.html")
# Add explicit route for /model/index.txt to redirect to /model
@router.get("/model/index.txt", include_in_schema=False)
async def redirect_model_index_txt() -> RedirectResponse:
return RedirectResponse("/model")
@router.get("/providers", include_in_schema=False)
async def serve_providers_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
# Add explicit route for /providers/index.txt to redirect to /providers
@router.get("/providers/index.txt", include_in_schema=False)
async def redirect_providers_index_txt() -> RedirectResponse:
return RedirectResponse("/providers")
@router.get("/settings", include_in_schema=False)
async def serve_settings_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
# Add explicit route for /settings/index.txt to redirect to /settings
@router.get("/settings/index.txt", include_in_schema=False)
async def redirect_settings_index_txt() -> RedirectResponse:
return RedirectResponse("/settings")
@router.get("/transactions", include_in_schema=False)
async def serve_transactions_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
# Add explicit route for /transactions/index.txt to redirect to /transactions
@router.get("/transactions/index.txt", include_in_schema=False)
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
@router.get("/balances", include_in_schema=False)
async def serve_balances_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
# Add explicit route for /balances/index.txt to redirect to /balances
@router.get("/balances/index.txt", include_in_schema=False)
async def redirect_balances_index_txt() -> RedirectResponse:
return RedirectResponse("/balances")
@router.get("/logs", include_in_schema=False)
async def serve_logs_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
# Add explicit route for /logs/index.txt to redirect to /logs
@router.get("/logs/index.txt", include_in_schema=False)
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@router.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
# Add explicit route for /usage/index.txt to redirect to /usage
@router.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@router.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
@router.get("/unauthorized/index.txt", include_in_schema=False)
async def redirect_unauthorized_index_txt() -> RedirectResponse:
return RedirectResponse("/unauthorized")
@router.get("/favicon.ico", include_in_schema=False)
async def serve_favicon() -> FileResponse:
icon_path = UI_DIST_PATH / "icon.ico"
if icon_path.exists():
return FileResponse(icon_path)
return FileResponse(UI_DIST_PATH / "favicon.ico")
@router.get("/icon.ico", include_in_schema=False)
async def serve_icon() -> FileResponse:
return FileResponse(UI_DIST_PATH / "icon.ico")
app.include_router(router)
app.mount(
"/static",
StaticFiles(directory=UI_DIST_PATH, check_dir=True),
name="ui-static",
)
else:
logger.warning(
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
)
router = APIRouter()
@router.get("/", include_in_schema=False)
async def root_fallback() -> dict:
return {
"name": global_settings.name,
"description": global_settings.description,
"version": app.version,
"status": "running",
"ui": "not available",
}
app.include_router(router)
-270
View File
@@ -1,270 +0,0 @@
import hashlib
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from .core.db import ApiKey, LightningInvoice, get_session
from .core.logging import get_logger
from .core.settings import settings
from .wallet import get_wallet
logger = get_logger(__name__)
lightning_router = APIRouter(prefix="/lightning")
class InvoiceCreateRequest(BaseModel):
amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis")
purpose: str = Field(description="create or topup", pattern="^(create|topup)$")
api_key: str | None = Field(
default=None, description="Required for topup operations"
)
class InvoiceCreateResponse(BaseModel):
invoice_id: str
bolt11: str
amount_sats: int
expires_at: int
payment_hash: str
class InvoiceStatusResponse(BaseModel):
status: str
api_key: str | None = None
amount_sats: int
paid_at: int | None = None
created_at: int
expires_at: int
class InvoiceRecoverRequest(BaseModel):
bolt11: str = Field(description="BOLT11 invoice string")
async def generate_lightning_invoice(
amount_sats: int, description: str
) -> tuple[str, str]:
wallet = await get_wallet(settings.primary_mint, "sat")
quote = await wallet.request_mint(amount_sats)
return quote.request, quote.quote
def generate_invoice_id() -> str:
return secrets.token_urlsafe(16)
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
async def create_invoice(
request: InvoiceCreateRequest,
session: AsyncSession = Depends(get_session),
) -> InvoiceCreateResponse:
if request.purpose == "topup" and not request.api_key:
raise HTTPException(
status_code=400, detail="api_key is required for topup operations"
)
if request.purpose == "topup" and request.api_key:
if not request.api_key.startswith("sk-"):
raise HTTPException(status_code=400, detail="Invalid API key format")
api_key = await session.get(ApiKey, request.api_key[3:])
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
try:
description = f"Routstr {request.purpose} {request.amount_sats} sats"
bolt11, payment_hash = await generate_lightning_invoice(
request.amount_sats, description
)
invoice_id = generate_invoice_id()
expires_at = int(time.time()) + 3600 # 1 hour expiry
invoice = LightningInvoice(
id=invoice_id,
bolt11=bolt11,
amount_sats=request.amount_sats,
description=description,
payment_hash=payment_hash,
status="pending",
api_key_hash=request.api_key[3:] if request.api_key else None,
purpose=request.purpose,
expires_at=expires_at,
)
session.add(invoice)
await session.commit()
logger.info(
"Lightning invoice created",
extra={
"invoice_id": invoice_id,
"amount_sats": request.amount_sats,
"purpose": request.purpose,
"expires_at": expires_at,
},
)
return InvoiceCreateResponse(
invoice_id=invoice_id,
bolt11=bolt11,
amount_sats=request.amount_sats,
expires_at=expires_at,
payment_hash=payment_hash,
)
except Exception as e:
logger.error(f"Failed to create Lightning invoice: {e}")
raise HTTPException(
status_code=500, detail="Failed to create Lightning invoice"
)
@lightning_router.get(
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
)
async def get_invoice_status(
invoice_id: str,
session: AsyncSession = Depends(get_session),
) -> InvoiceStatusResponse:
invoice = await session.get(LightningInvoice, invoice_id)
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
invoice.status = "expired"
await session.commit()
if invoice.status == "pending":
await check_invoice_payment(invoice, session)
api_key = None
if invoice.status == "paid" and invoice.purpose == "create":
if invoice.api_key_hash:
api_key = f"sk-{invoice.api_key_hash}"
elif (
invoice.status == "paid" and invoice.purpose == "topup" and invoice.api_key_hash
):
api_key = f"sk-{invoice.api_key_hash}"
return InvoiceStatusResponse(
status=invoice.status,
api_key=api_key,
amount_sats=invoice.amount_sats,
paid_at=invoice.paid_at,
created_at=invoice.created_at,
expires_at=invoice.expires_at,
)
@lightning_router.post("/recover", response_model=InvoiceStatusResponse)
async def recover_invoice(
request: InvoiceRecoverRequest,
session: AsyncSession = Depends(get_session),
) -> InvoiceStatusResponse:
result = await session.exec(
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
)
invoice = result.first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.status == "pending":
await check_invoice_payment(invoice, session)
api_key = None
if invoice.status == "paid":
if invoice.purpose == "create" and invoice.api_key_hash:
api_key = f"sk-{invoice.api_key_hash}"
elif invoice.purpose == "topup" and invoice.api_key_hash:
api_key = f"sk-{invoice.api_key_hash}"
return InvoiceStatusResponse(
status=invoice.status,
api_key=api_key,
amount_sats=invoice.amount_sats,
paid_at=invoice.paid_at,
created_at=invoice.created_at,
expires_at=invoice.expires_at,
)
async def check_invoice_payment(
invoice: LightningInvoice, session: AsyncSession
) -> None:
try:
wallet = await get_wallet(settings.primary_mint, "sat")
mint_status = await wallet.get_mint_quote(invoice.payment_hash)
if mint_status.paid:
invoice.status = "paid"
invoice.paid_at = int(time.time())
if invoice.purpose == "create":
api_key = await create_api_key_from_invoice(invoice, session)
invoice.api_key_hash = api_key.hashed_key
elif invoice.purpose == "topup" and invoice.api_key_hash:
await topup_api_key_from_invoice(invoice, session)
await session.commit()
logger.info(
"Lightning invoice paid",
extra={
"invoice_id": invoice.id,
"amount_sats": invoice.amount_sats,
"purpose": invoice.purpose,
"api_key_hash": invoice.api_key_hash[:8] + "..."
if invoice.api_key_hash
else None,
},
)
except Exception as e:
logger.error(f"Failed to check invoice payment: {e}")
async def create_api_key_from_invoice(
invoice: LightningInvoice, session: AsyncSession
) -> ApiKey:
wallet = await get_wallet(settings.primary_mint, "sat")
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
dummy_token = f"invoice-{invoice.id}-{invoice.payment_hash}"
hashed_key = hashlib.sha256(dummy_token.encode()).hexdigest()
api_key = ApiKey(
hashed_key=hashed_key,
balance=invoice.amount_sats * 1000, # Convert to msats
refund_currency="sat",
refund_mint_url=settings.primary_mint,
)
session.add(api_key)
await session.flush()
return api_key
async def topup_api_key_from_invoice(
invoice: LightningInvoice, session: AsyncSession
) -> None:
wallet = await get_wallet(settings.primary_mint, "sat")
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
if not invoice.api_key_hash:
raise ValueError("No API key associated with topup invoice")
api_key = await session.get(ApiKey, invoice.api_key_hash)
if not api_key:
raise ValueError("Associated API key not found")
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
await session.flush()
+9
View File
@@ -0,0 +1,9 @@
from .metadata import async_fetch_openrouter_models
from .models import Model
__all__ = [
"Model",
"async_fetch_openrouter_models",
]
# specifically ai models that the node sells and not meaning database models
@@ -2,11 +2,13 @@
from typing import TYPE_CHECKING
from .core.logging import get_logger
from ..core import get_logger
from ..models.crud import row_to_model
from ..upstream.helpers import resolve_model_alias
if TYPE_CHECKING:
from .payment.models import Model
from .upstream import BaseUpstreamProvider
from ..upstream import BaseUpstreamProvider
from .models import Model
logger = get_logger(__name__)
@@ -150,6 +152,18 @@ def should_prefer_model(
# Prefer lower adjusted cost
should_replace = candidate_adjusted < current_adjusted
# Log provider changes when candidate wins
if should_replace:
candidate_provider_name = getattr(
candidate_provider, "upstream_name", "unknown"
)
current_provider_name = getattr(current_provider, "upstream_name", "unknown")
logger.debug(
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
f"(cost: ${current_adjusted:.6f})"
)
return should_replace
@@ -180,8 +194,6 @@ def create_model_mappings(
Returns:
Tuple of (model_instances, provider_map, unique_models)
"""
from .payment.models import _row_to_model
from .upstream.helpers import resolve_model_alias
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, "BaseUpstreamProvider"] = {}
@@ -233,7 +245,7 @@ def create_model_mappings(
# Apply overrides if present
if model.id in overrides_by_id:
override_row, provider_fee = overrides_by_id[model.id]
model_to_use = _row_to_model(
model_to_use = row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
else:
@@ -242,12 +254,7 @@ def create_model_mappings(
# Add to unique models
base_id = get_base_model_id(model_to_use.id)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream.provider_type,
}
)
unique_model = model_to_use.copy(update={"id": base_id})
unique_models[base_id] = unique_model
# Get all aliases for this model
@@ -282,8 +289,12 @@ def create_model_mappings(
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
logger.debug(
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
extra={"provider_distribution": provider_counts},
"Created model mappings",
extra={
"unique_model_count": len(unique_models),
"total_alias_count": len(model_instances),
"provider_distribution": provider_counts,
},
)
return model_instances, provider_map, unique_models
+105
View File
@@ -0,0 +1,105 @@
import json
from ..core import get_logger
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow
from ..payment.price import sats_usd_price
from .models import (
Architecture,
Model,
Pricing,
TopProvider,
_update_model_sats_pricing,
calculate_usd_max_costs,
)
logger = get_logger(__name__)
def row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
architecture = json.loads(row.architecture)
pricing = json.loads(row.pricing)
per_request_limits = (
json.loads(row.per_request_limits) if row.per_request_limits else None
)
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
if apply_provider_fee and isinstance(pricing, dict):
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
parsed_pricing = Pricing.parse_obj(pricing)
model = Model(
id=row.id,
name=row.name,
created=row.created,
description=row.description,
context_length=row.context_length,
architecture=Architecture.parse_obj(architecture),
pricing=parsed_pricing,
sats_pricing=None,
per_request_limits=per_request_limits,
top_provider=TopProvider.parse_obj(top_provider_dict)
if top_provider_dict
else None,
enabled=row.enabled,
upstream_provider_id=row.upstream_provider_id,
canonical_slug=getattr(row, "canonical_slug", None),
)
if apply_provider_fee:
(
parsed_pricing.max_prompt_cost,
parsed_pricing.max_completion_cost,
parsed_pricing.max_cost,
) = calculate_usd_max_costs(model)
try:
sats_to_usd = sats_usd_price()
model = _update_model_sats_pricing(model, sats_to_usd)
except Exception as e:
logger.warning(f"Could not calculate sats pricing: {e}")
return model
async def list_models(
session: AsyncSession,
upstream_id: int,
include_disabled: bool = False,
) -> list[Model]:
from sqlmodel import select
query = select(ModelRow)
if upstream_id is not None:
query = query.where(ModelRow.upstream_provider_id == upstream_id)
if not include_disabled:
query = query.where(ModelRow.enabled)
rows = (await session.exec(query)).all() # type: ignore
provider_result = await session.exec(select(UpstreamProviderRow))
providers_by_id = {p.id: p for p in provider_result.all()}
return [
row_to_model(
r,
apply_provider_fee=True,
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
if r.upstream_provider_id in providers_by_id
else 1.01,
)
for r in rows
]
async def get_model_by_id(
model_id: str, provider_id: int, session: AsyncSession
) -> Model | None:
row = await session.get(ModelRow, (model_id, provider_id))
if not row or not row.enabled:
return None
provider = await session.get(UpstreamProviderRow, provider_id)
provider_fee = provider.provider_fee if provider else 1.01
return row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
+52
View File
@@ -0,0 +1,52 @@
from typing import Final
import httpx
from ..core import get_logger
logger = get_logger(__name__)
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
"openrouter/auto",
"google/gemini-2.5-pro-exp-03-25",
"opengvlab/internvl3-78b",
"openrouter/sonoma-dusk-alpha",
"openrouter/sonoma-sky-alpha",
}
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Asynchronously fetch model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/models", timeout=30)
response.raise_for_status()
data = response.json()
models_data: list[dict] = []
for model in data.get("data", []):
model_id = model.get("id", "")
if source_filter:
source_prefix = f"{source_filter}/"
if not model_id.startswith(source_prefix):
continue
model = dict(model)
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if (
"(free)" in model.get("name", "")
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
continue
models_data.append(model)
return models_data
except Exception as e:
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
return []
@@ -2,7 +2,6 @@ import asyncio
import json
import random
import httpx
from fastapi import APIRouter, Depends
from pydantic.v1 import BaseModel
from sqlmodel import select
@@ -11,7 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..core.db import ModelRow, create_session, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_price
from ..payment.price import sats_usd_price
logger = get_logger(__name__)
@@ -29,12 +28,10 @@ class Architecture(BaseModel):
class Pricing(BaseModel):
prompt: float
completion: float
request: float = 0.0
image: float = 0.0
web_search: float = 0.0
internal_reasoning: float = 0.0
input_cache_read: float = 0.0
input_cache_write: float = 0.0
request: float
image: float
web_search: float
internal_reasoning: float
max_prompt_cost: float = 0.0 # in sats not msats
max_completion_cost: float = 0.0 # in sats not msats
max_cost: float = 0.0 # in sats not msats
@@ -58,7 +55,7 @@ class Model(BaseModel):
per_request_limits: dict | None = None
top_provider: TopProvider | None = None
enabled: bool = True
upstream_provider_id: int | str | None = None
upstream_provider_id: int | None = None
canonical_slug: str | None = None
alias_ids: list[str] | None = None
@@ -66,64 +63,6 @@ class Model(BaseModel):
return hash(self.id)
def _has_valid_pricing(model: dict) -> bool:
"""Check if model has valid pricing (not free, no negative values)."""
pricing = model.get("pricing", {})
if not pricing:
return False
try:
prompt = float(pricing.get("prompt", 0))
completion = float(pricing.get("completion", 0))
except (ValueError, TypeError):
return False
if prompt < 0 or completion < 0:
return False
if prompt == 0 and completion == 0:
return False
return True
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Asynchronously fetch model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/models", timeout=30)
response.raise_for_status()
data = response.json()
models_data: list[dict] = []
for model in data.get("data", []):
model_id = model.get("id", "")
if source_filter:
source_prefix = f"{source_filter}/"
if not model_id.startswith(source_prefix):
continue
model = dict(model)
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if "(free)" in model.get("name", ""):
continue
if not _has_valid_pricing(model):
continue
models_data.append(model)
return models_data
except Exception as e:
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
return []
def is_openrouter_upstream() -> bool:
try:
base = (settings.upstream_base_url or "").strip().rstrip("/")
@@ -132,124 +71,7 @@ def is_openrouter_upstream() -> bool:
return base.lower() == "https://openrouter.ai/api/v1"
def _row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
architecture = json.loads(row.architecture)
pricing = json.loads(row.pricing)
per_request_limits = (
json.loads(row.per_request_limits) if row.per_request_limits else None
)
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
if apply_provider_fee and isinstance(pricing, dict):
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
parsed_pricing = Pricing.parse_obj(pricing)
model = Model(
id=row.id,
name=row.name,
created=row.created,
description=row.description,
context_length=row.context_length,
architecture=Architecture.parse_obj(architecture),
pricing=parsed_pricing,
sats_pricing=None,
per_request_limits=per_request_limits,
top_provider=TopProvider.parse_obj(top_provider_dict)
if top_provider_dict
else None,
enabled=row.enabled,
upstream_provider_id=row.upstream_provider_id,
canonical_slug=getattr(row, "canonical_slug", None),
)
if apply_provider_fee:
(
parsed_pricing.max_prompt_cost,
parsed_pricing.max_completion_cost,
parsed_pricing.max_cost,
) = _calculate_usd_max_costs(model)
try:
sats_to_usd = sats_usd_price()
model = _update_model_sats_pricing(model, sats_to_usd)
except Exception as e:
logger.warning(f"Could not calculate sats pricing: {e}")
return model
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
return {
"id": model.id,
"name": model.name,
"created": model.created,
"description": model.description,
"context_length": model.context_length,
"architecture": json.dumps(model.architecture.dict()),
"pricing": json.dumps(model.pricing.dict()),
"sats_pricing": json.dumps(model.sats_pricing.dict())
if model.sats_pricing
else None,
"per_request_limits": json.dumps(model.per_request_limits)
if model.per_request_limits is not None
else None,
"top_provider": json.dumps(model.top_provider.dict())
if model.top_provider is not None
else None,
"enabled": model.enabled,
"upstream_provider_id": model.upstream_provider_id,
}
async def list_models(
session: AsyncSession,
upstream_id: int,
include_disabled: bool = False,
) -> list[Model]:
from sqlmodel import select
from ..core.db import UpstreamProviderRow
query = select(ModelRow)
if upstream_id is not None:
query = query.where(ModelRow.upstream_provider_id == upstream_id)
if not include_disabled:
query = query.where(ModelRow.enabled)
rows = (await session.exec(query)).all() # type: ignore
provider_result = await session.exec(select(UpstreamProviderRow))
providers_by_id = {p.id: p for p in provider_result.all()}
return [
_row_to_model(
r,
apply_provider_fee=True,
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
if r.upstream_provider_id in providers_by_id
else 1.01,
)
for r in rows
]
async def get_model_by_id(
model_id: str, provider_id: int, session: AsyncSession
) -> Model | None:
from ..core.db import UpstreamProviderRow
row = await session.get(ModelRow, (model_id, provider_id))
if not row or not row.enabled:
return None
provider = await session.get(UpstreamProviderRow, provider_id)
provider_fee = provider.provider_fee if provider else 1.01
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
def calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
"""Calculate max costs in USD based on model context/token limits.
Args:
@@ -521,6 +343,29 @@ def _pricing_matches(
return True
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
return {
"id": model.id,
"name": model.name,
"created": model.created,
"description": model.description,
"context_length": model.context_length,
"architecture": json.dumps(model.architecture.dict()),
"pricing": json.dumps(model.pricing.dict()),
"sats_pricing": json.dumps(model.sats_pricing.dict())
if model.sats_pricing
else None,
"per_request_limits": json.dumps(model.per_request_limits)
if model.per_request_limits is not None
else None,
"top_provider": json.dumps(model.top_provider.dict())
if model.top_provider is not None
else None,
"enabled": model.enabled,
"upstream_provider_id": model.upstream_provider_id,
}
@models_router.get("/v1/models")
@models_router.get("/models", include_in_schema=False)
async def models(session: AsyncSession = Depends(get_session)) -> dict:
+7
View File
@@ -0,0 +1,7 @@
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
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__)
@@ -62,9 +62,7 @@ async def query_nostr_relay_for_providers(
if data[0] == "EVENT" and data[1] == sub_id:
event = data[2]
logger.debug(
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
)
logger.debug(f"Found provider announcement: {event['id']}")
events.append(event)
elif data[0] == "EOSE" and data[1] == sub_id:
logger.debug("Received EOSE message")
+12 -12
View File
@@ -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],
@@ -164,7 +164,7 @@ 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,
@@ -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())
@@ -215,7 +215,7 @@ async def query_nip91_events(
continue
events_out.append(ev_dict)
logger.debug(
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
)
if drained:
last_event_ts = time.time()
@@ -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 []
@@ -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)
@@ -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:
+1 -1
View File
@@ -1,4 +1,4 @@
from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
from .cost import CostData, CostDataError, MaxCostData, calculate_cost
__all__ = [
"CostData",
+48
View File
@@ -0,0 +1,48 @@
from cashu.wallet.helpers import deserialize_token_from_string
from fastapi import HTTPException
from ..core import get_logger
logger = get_logger(__name__)
def get_token_balance_msat(cashu_token: str) -> int:
try:
token = deserialize_token_from_string(cashu_token)
except Exception:
raise HTTPException(401, "Invalid authentication token format")
if token.unit == "sat":
return token.amount * 1000
if token.unit == "msat":
return token.amount
raise HTTPException(401, f"Unit {token.unit} not supported yet")
def pre_check_header_token_balance(
headers: dict, body: dict, max_cost_for_model: int
) -> None:
if x_cashu := headers.get("x-cashu", None):
cashu_token = x_cashu
elif auth := headers.get("authorization", None):
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
else:
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle regular API keys (sk-*)
if cashu_token.startswith("sk-"):
return
amount_msat = get_token_balance_msat(cashu_token)
if max_cost_for_model > amount_msat:
raise HTTPException(
status_code=413,
detail={
"reason": "Insufficient balance",
"amount_required_msat": max_cost_for_model,
"model": body.get("model", "unknown"),
"type": "minimum_balance_required",
},
)
+425
View File
@@ -0,0 +1,425 @@
import base64
import math
from io import BytesIO
from typing import Any
import httpx
from PIL import Image
from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.settings import settings
from ..models.models import Model
logger = get_logger(__name__)
class CostData(BaseModel):
base_msats: int
input_msats: int
output_msats: int
total_msats: int
class MaxCostData(CostData):
pass
class CostDataError(BaseModel):
message: str
code: str
def calculate_cost(
response_data: dict, max_cost: int
) -> CostData | MaxCostData | CostDataError:
"""
Calculate the cost of an API request based on token usage.
Args:
response_data: Response data containing usage information
max_cost: Maximum cost in millisats
Returns:
Cost data or error information
"""
logger.debug(
"Starting cost calculation",
extra={
"max_cost_msats": max_cost,
"has_usage_data": "usage" in response_data,
"response_model": response_data.get("model", "unknown"),
},
)
cost_data = MaxCostData(
base_msats=max_cost,
input_msats=0,
output_msats=0,
total_msats=max_cost,
)
if "usage" not in response_data or response_data["usage"] is None:
logger.warning(
"No usage data in response, using base cost only",
extra={
"max_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
},
)
return cost_data
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
MSATS_PER_1K_OUTPUT_TOKENS: float = (
float(settings.fixed_per_1k_output_tokens) * 1000.0
)
if not settings.fixed_pricing:
response_model = response_data.get("model", "")
logger.debug(
"Using model-based pricing",
extra={"model": response_model},
)
from ..proxy import get_model_instance
model_obj = get_model_instance(response_model)
if not model_obj:
logger.error(
"Invalid model in response",
extra={"response_model": response_model},
)
return CostDataError(
message=f"Invalid model in response: {response_model}",
code="model_not_found",
)
if not model_obj.sats_pricing:
logger.error(
"Model pricing not defined",
extra={"model": response_model, "model_id": response_model},
)
return CostDataError(
message="Model pricing not defined", code="pricing_not_found"
)
try:
mspp = float(model_obj.sats_pricing.prompt)
mspc = float(model_obj.sats_pricing.completion)
except Exception:
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
logger.info(
"Applied model-specific pricing",
extra={
"model": response_model,
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
},
)
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
logger.warning(
"No token pricing configured, using base cost",
extra={
"base_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
},
)
return cost_data
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
logger.info(
"Calculated token-based cost",
extra={
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
"total_cost_msats": token_based_cost,
"model": response_data.get("model", "unknown"),
},
)
return CostData(
base_msats=0,
input_msats=int(input_msats),
output_msats=int(output_msats),
total_msats=token_based_cost,
)
def get_max_cost_for_model(model_obj: Model) -> int:
"""Get the maximum cost for a specific model from providers with overrides."""
if settings.fixed_pricing:
default_cost_msats = settings.fixed_cost_per_request * 1000
return max(settings.min_request_msat, default_cost_msats)
if model_obj.sats_pricing:
max_cost = (
model_obj.sats_pricing.max_cost
* 1000
* (1 - settings.tolerance_percentage / 100)
)
calculated_msats = int(max_cost)
return max(settings.min_request_msat, calculated_msats)
logger.warning(
"Model pricing not found, using fixed cost",
extra={
"model": model_obj.id,
"default_cost_msats": settings.fixed_cost_per_request * 1000,
},
)
return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000)
async def calculate_discounted_max_cost(
max_cost_for_model: int,
body: dict,
model_obj: Any | None = None,
) -> int:
"""Calculate the discounted max cost for a request using model pricing when available."""
if settings.fixed_pricing:
return max_cost_for_model
model = body.get("model", "unknown")
model_pricing = model_obj.sats_pricing if model_obj else None
if not model_pricing:
return max_cost_for_model
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
adjusted = max_cost_for_model
if messages := body.get("messages"):
prompt_tokens = _estimate_tokens(messages)
image_tokens = await _estimate_image_tokens_in_messages(messages)
if image_tokens > 0:
logger.debug(
"Found images in request",
extra={
"model": model,
"image_tokens": image_tokens,
},
)
prompt_tokens += image_tokens
estimated_prompt_delta_sats = (
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
)
if estimated_prompt_delta_sats > 0:
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
max_tokens_raw = body.get("max_tokens", None)
if max_tokens_raw is not None:
try:
max_tokens_int = int(max_tokens_raw)
except (TypeError, ValueError):
logger.warning(
"Invalid max_tokens; ignoring in cost adjustment",
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
)
else:
estimated_completion_delta_sats = (
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
)
if estimated_completion_delta_sats > 0:
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
logger.debug(
"Discounted max cost computed",
extra={
"model": model,
"original_msats": max_cost_for_model,
"adjusted_msats": adjusted,
"tolerance_pct": tol,
},
)
return max(0, adjusted)
def _estimate_tokens(messages: list) -> int:
"""Estimate tokens for text content, excluding image_url fields."""
total = 0
for msg in messages:
if isinstance(msg, dict):
content = msg.get("content")
if isinstance(content, str):
total += len(content)
elif isinstance(content, list):
total += sum(
len(item.get("text", ""))
for item in content
if isinstance(item, dict) and item.get("type") == "text"
)
return total // 3
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
"""Extract image dimensions from image bytes."""
try:
img = Image.open(BytesIO(image_data))
return img.size
except Exception as e:
logger.warning(
"Failed to get image dimensions, using default",
extra={"error": str(e)},
)
return (512, 512)
async def _fetch_image_from_url(url: str) -> bytes | None:
"""Fetch image from URL."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.content
except Exception as e:
logger.warning(
"Failed to fetch image from URL",
extra={"error": str(e), "url": url[:100]},
)
return None
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
"""Calculate image tokens based on OpenAI's vision pricing.
For low detail: 85 tokens
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
"""
if detail == "low":
return 85
if width > 2048 or height > 2048:
aspect_ratio = width / height
if width > height:
width = 2048
height = int(width / aspect_ratio)
else:
height = 2048
width = int(height * aspect_ratio)
if width > 768 or height > 768:
aspect_ratio = width / height
if width > height:
width = 768
height = int(width / aspect_ratio)
else:
height = 768
width = int(height * aspect_ratio)
tiles_width = (width + 511) // 512
tiles_height = (height + 511) // 512
num_tiles = tiles_width * tiles_height
return 85 + (170 * num_tiles)
async def _estimate_image_tokens_in_messages(messages: list) -> int:
"""Estimate total tokens for all images in messages.
Supports both base64 encoded images and image URLs.
"""
total_image_tokens = 0
for message in messages:
if not isinstance(message, dict):
continue
content = message.get("content")
if not content:
continue
if isinstance(content, str):
continue
if not isinstance(content, list):
continue
for content_item in content:
if not isinstance(content_item, dict):
continue
content_type = content_item.get("type")
if content_type not in ("image_url", "input_image"):
continue
image_url_data = content_item.get("image_url")
if not image_url_data:
continue
if isinstance(image_url_data, str):
url = image_url_data
detail = "auto"
elif isinstance(image_url_data, dict):
url = image_url_data.get("url", "")
detail = image_url_data.get("detail", "auto")
else:
continue
if not url:
continue
if url.startswith("data:image/"):
try:
header, base64_data = url.split(",", 1)
image_bytes = base64.b64decode(base64_data)
width, height = _get_image_dimensions(image_bytes)
tokens = _calculate_image_tokens(width, height, detail)
total_image_tokens += tokens
logger.debug(
"Calculated tokens for base64 image",
extra={
"width": width,
"height": height,
"detail": detail,
"tokens": tokens,
},
)
except Exception as e:
logger.warning(
"Failed to process base64 image",
extra={"error": str(e)},
)
total_image_tokens += 85
else:
image_bytes_or_none = await _fetch_image_from_url(url)
if image_bytes_or_none:
width, height = _get_image_dimensions(image_bytes_or_none)
tokens = _calculate_image_tokens(width, height, detail)
total_image_tokens += tokens
logger.debug(
"Calculated tokens for URL image",
extra={
"url": url[:100],
"width": width,
"height": height,
"detail": detail,
"tokens": tokens,
},
)
else:
total_image_tokens += 85
return total_image_tokens
-169
View File
@@ -1,169 +0,0 @@
import math
from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.db import AsyncSession
from ..core.settings import settings
logger = get_logger(__name__)
class CostData(BaseModel):
base_msats: int
input_msats: int
output_msats: int
total_msats: int
class MaxCostData(CostData):
pass
class CostDataError(BaseModel):
message: str
code: str
async def calculate_cost( # todo: can be sync
response_data: dict, max_cost: int, session: AsyncSession
) -> CostData | MaxCostData | CostDataError:
"""
Calculate the cost of an API request based on token usage.
Args:
response_data: Response data containing usage information
max_cost: Maximum cost in millisats
Returns:
Cost data or error information
"""
logger.debug(
"Starting cost calculation",
extra={
"max_cost_msats": max_cost,
"has_usage_data": "usage" in response_data,
"response_model": response_data.get("model", "unknown"),
},
)
cost_data = MaxCostData(
base_msats=max_cost,
input_msats=0,
output_msats=0,
total_msats=max_cost,
)
if "usage" not in response_data or response_data["usage"] is None:
logger.warning(
"No usage data in response, using base cost only",
extra={
"max_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
},
)
return cost_data
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
MSATS_PER_1K_OUTPUT_TOKENS: float = (
float(settings.fixed_per_1k_output_tokens) * 1000.0
)
if not settings.fixed_pricing:
response_model = response_data.get("model", "")
logger.debug(
"Using model-based pricing",
extra={"model": response_model},
)
from ..proxy import get_model_instance
model_obj = get_model_instance(response_model)
if not model_obj:
logger.error(
"Invalid model in response",
extra={"response_model": response_model},
)
return CostDataError(
message=f"Invalid model in response: {response_model}",
code="model_not_found",
)
if not model_obj.sats_pricing:
logger.error(
"Model pricing not defined",
extra={"model": response_model, "model_id": response_model},
)
return CostDataError(
message="Model pricing not defined", code="pricing_not_found"
)
try:
mspp = float(model_obj.sats_pricing.prompt)
mspc = float(model_obj.sats_pricing.completion)
except Exception:
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
logger.info(
"Applied model-specific pricing",
extra={
"model": response_model,
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
},
)
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
logger.warning(
"No token pricing configured, using base cost",
extra={
"base_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
},
)
return cost_data
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
# added for response api
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
logger.info(
"Calculated token-based cost",
extra={
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
"total_cost_msats": token_based_cost,
"model": response_data.get("model", "unknown"),
},
)
return CostData(
base_msats=0,
input_msats=int(input_msats),
output_msats=int(output_msats),
total_msats=token_based_cost,
)
+381 -382
View File
@@ -1,396 +1,18 @@
import base64
import json
import math
from io import BytesIO
from typing import Any
import httpx
from fastapi import HTTPException, Response
from fastapi.requests import Request
from PIL import Image
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel import col, update
from ..core import get_logger
from ..core.settings import settings
from ..wallet import deserialize_token_from_string
from ..core.db import AsyncSession, TemporaryCredit
from ..payment.cost import CostData, CostDataError, MaxCostData, calculate_cost
logger = get_logger(__name__)
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
if x_cashu := headers.get("x-cashu", None):
cashu_token = x_cashu
logger.debug(
"Using X-Cashu token",
extra={
"token_preview": cashu_token[:20] + "..."
if len(cashu_token) > 20
else cashu_token
},
)
elif auth := headers.get("authorization", None):
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
logger.debug(
"Using Authorization header token",
extra={
"token_preview": cashu_token[:20] + "..."
if len(cashu_token) > 20
else cashu_token
},
)
else:
logger.error("No authentication token provided")
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle empty token
if not cashu_token:
logger.error("Empty token provided")
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "API key or Cashu token required",
"type": "invalid_request_error",
"code": "missing_api_key",
}
},
)
# Handle regular API keys (sk-*)
if cashu_token.startswith("sk-"):
return
try:
token_obj = deserialize_token_from_string(cashu_token)
except Exception:
# Invalid token format - let the auth system handle it
raise HTTPException(
status_code=401,
detail="Invalid authentication token format",
)
amount_msat = (
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
)
if max_cost_for_model > amount_msat:
raise HTTPException(
status_code=413,
detail={
"reason": "Insufficient balance",
"amount_required_msat": max_cost_for_model,
"model": body.get("model", "unknown"),
"type": "minimum_balance_required",
},
)
async def get_max_cost_for_model(
model: str,
session: AsyncSession,
model_obj: Any | None = None,
) -> int:
"""Get the maximum cost for a specific model from providers with overrides."""
logger.debug(
"Getting max cost for model",
extra={
"model": model,
"fixed_pricing": settings.fixed_pricing,
},
)
if settings.fixed_pricing:
default_cost_msats = settings.fixed_cost_per_request * 1000
logger.debug(
"Using fixed cost pricing",
extra={"cost_msats": default_cost_msats, "model": model},
)
return max(settings.min_request_msat, default_cost_msats)
if not model_obj:
from ..proxy import get_model_instance
model_obj = get_model_instance(model)
if not model_obj:
fallback_msats = settings.fixed_cost_per_request * 1000
logger.warning(
"Model not found in providers or overrides",
extra={
"requested_model": model,
"using_default_cost": fallback_msats,
},
)
return max(settings.min_request_msat, fallback_msats)
if model_obj.sats_pricing:
try:
max_cost = (
model_obj.sats_pricing.max_cost
* 1000
* (1 - settings.tolerance_percentage / 100)
)
logger.debug(
"Found model-specific max cost",
extra={"model": model, "max_cost_msats": max_cost},
)
calculated_msats = int(max_cost)
return max(settings.min_request_msat, calculated_msats)
except Exception as e:
logger.error(
"Error calculating max cost from model pricing",
extra={"model": model, "error": str(e)},
)
logger.warning(
"Model pricing not found, using fixed cost",
extra={
"model": model,
"default_cost_msats": settings.fixed_cost_per_request * 1000,
},
)
return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000)
async def calculate_discounted_max_cost(
max_cost_for_model: int,
body: dict,
model_obj: Any | None = None,
) -> int:
"""Calculate the discounted max cost for a request using model pricing when available."""
if settings.fixed_pricing:
return max_cost_for_model
model = body.get("model", "unknown")
model_pricing = model_obj.sats_pricing if model_obj else None
if not model_pricing:
return max_cost_for_model
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
adjusted = max_cost_for_model
if messages := body.get("messages"):
prompt_tokens = estimate_tokens(messages)
image_tokens = await estimate_image_tokens_in_messages(messages)
if image_tokens > 0:
logger.debug(
"Found images in request",
extra={
"model": model,
"image_tokens": image_tokens,
},
)
prompt_tokens += image_tokens
estimated_prompt_delta_sats = (
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
)
if estimated_prompt_delta_sats > 0:
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
max_tokens_raw = body.get("max_tokens", None)
if max_tokens_raw is not None:
try:
max_tokens_int = int(max_tokens_raw)
except (TypeError, ValueError):
logger.warning(
"Invalid max_tokens; ignoring in cost adjustment",
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
)
else:
estimated_completion_delta_sats = (
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
)
if estimated_completion_delta_sats > 0:
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
logger.debug(
"Discounted max cost computed",
extra={
"model": model,
"original_msats": max_cost_for_model,
"adjusted_msats": adjusted,
"tolerance_pct": tol,
},
)
return max(0, adjusted)
def estimate_tokens(messages: list) -> int:
"""Estimate tokens for text content, excluding image_url fields."""
total = 0
for msg in messages:
if isinstance(msg, dict):
content = msg.get("content")
if isinstance(content, str):
total += len(content)
elif isinstance(content, list):
total += sum(
len(item.get("text", ""))
for item in content
if isinstance(item, dict) and item.get("type") == "text"
)
return total // 3
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
"""Extract image dimensions from image bytes."""
try:
img = Image.open(BytesIO(image_data))
return img.size
except Exception as e:
logger.warning(
"Failed to get image dimensions, using default",
extra={"error": str(e)},
)
return (512, 512)
async def _fetch_image_from_url(url: str) -> bytes | None:
"""Fetch image from URL."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.content
except Exception as e:
logger.warning(
"Failed to fetch image from URL",
extra={"error": str(e), "url": url[:100]},
)
return None
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
"""Calculate image tokens based on OpenAI's vision pricing.
For low detail: 85 tokens
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
"""
if detail == "low":
return 85
if width > 2048 or height > 2048:
aspect_ratio = width / height
if width > height:
width = 2048
height = int(width / aspect_ratio)
else:
height = 2048
width = int(height * aspect_ratio)
if width > 768 or height > 768:
aspect_ratio = width / height
if width > height:
width = 768
height = int(width / aspect_ratio)
else:
height = 768
width = int(height * aspect_ratio)
tiles_width = (width + 511) // 512
tiles_height = (height + 511) // 512
num_tiles = tiles_width * tiles_height
return 85 + (170 * num_tiles)
async def estimate_image_tokens_in_messages(messages: list) -> int:
"""Estimate total tokens for all images in messages.
Supports both base64 encoded images and image URLs.
"""
total_image_tokens = 0
for message in messages:
if not isinstance(message, dict):
continue
content = message.get("content")
if not content:
continue
if isinstance(content, str):
continue
if not isinstance(content, list):
continue
for content_item in content:
if not isinstance(content_item, dict):
continue
content_type = content_item.get("type")
if content_type not in ("image_url", "input_image"):
continue
image_url_data = content_item.get("image_url")
if not image_url_data:
continue
if isinstance(image_url_data, str):
url = image_url_data
detail = "auto"
elif isinstance(image_url_data, dict):
url = image_url_data.get("url", "")
detail = image_url_data.get("detail", "auto")
else:
continue
if not url:
continue
if url.startswith("data:image/"):
try:
header, base64_data = url.split(",", 1)
image_bytes = base64.b64decode(base64_data)
width, height = _get_image_dimensions(image_bytes)
tokens = _calculate_image_tokens(width, height, detail)
total_image_tokens += tokens
logger.debug(
"Calculated tokens for base64 image",
extra={
"width": width,
"height": height,
"detail": detail,
"tokens": tokens,
},
)
except Exception as e:
logger.warning(
"Failed to process base64 image",
extra={"error": str(e)},
)
total_image_tokens += 85
else:
image_bytes_or_none = await _fetch_image_from_url(url)
if image_bytes_or_none:
width, height = _get_image_dimensions(image_bytes_or_none)
tokens = _calculate_image_tokens(width, height, detail)
total_image_tokens += tokens
logger.debug(
"Calculated tokens for URL image",
extra={
"url": url[:100],
"width": width,
"height": height,
"detail": detail,
"tokens": tokens,
},
)
else:
total_image_tokens += 85
return total_image_tokens
# TODO: remove and replace with custom HTTPException
def create_error_response(
error_type: str,
message: str,
@@ -414,3 +36,380 @@ def create_error_response(
media_type="application/json",
headers={"X-Cashu": token} if token else {},
)
# Request payment handlers, todo: maybe mode somewhere else...
async def pay_for_request(
key: TemporaryCredit, cost_per_request: int, session: AsyncSession
) -> int:
"""Process payment for a request."""
logger.info(
"Processing payment for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"current_balance": key.balance,
"required_cost": cost_per_request,
"sufficient_balance": key.balance >= cost_per_request,
},
)
if key.total_balance_msat < cost_per_request:
logger.warning(
"Insufficient balance for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"balance": key.balance,
"reserved_balance": key.reserved_balance,
"required": cost_per_request,
"shortfall": cost_per_request - key.total_balance_msat,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
logger.debug(
"Charging base cost for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost": cost_per_request,
"balance_before": key.balance,
},
)
# Charge the base cost for the request atomically to avoid race conditions
stmt = (
update(TemporaryCredit)
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
.where(
col(TemporaryCredit.balance) - col(TemporaryCredit.reserved_balance)
>= cost_per_request
)
.values(
reserved_balance=col(TemporaryCredit.reserved_balance) + cost_per_request
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Concurrent request depleted balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"required_cost": cost_per_request,
"current_balance": key.balance,
},
)
# Another concurrent request spent the balance first
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
await session.refresh(key)
logger.info(
"Payment processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": cost_per_request,
"new_balance": key.balance,
},
)
return cost_per_request
async def revert_pay_for_request(
key: TemporaryCredit, session: AsyncSession, cost_per_request: int
) -> None:
stmt = (
update(TemporaryCredit)
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(TemporaryCredit.reserved_balance) - cost_per_request
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to revert payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_to_revert": cost_per_request,
"current_reserved_balance": key.reserved_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
await session.refresh(key)
async def adjust_payment_for_tokens(
key: TemporaryCredit,
response_data: dict,
session: AsyncSession,
deducted_max_cost: int,
) -> dict:
"""
Adjusts the payment based on token usage in the response.
This is called after the initial payment and the upstream request is complete.
Returns cost data to be included in the response.
"""
model = response_data.get("model", "unknown")
logger.debug(
"Starting payment adjustment for tokens",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"deducted_max_cost": deducted_max_cost,
"current_balance": key.balance,
"has_usage": "usage" in response_data,
},
)
match calculate_cost(response_data, deducted_max_cost):
case MaxCostData() as cost:
logger.debug(
"Using max cost data (no token adjustment)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"max_cost": cost.total_msats,
},
)
# Finalize by releasing reservation and charging max cost
finalize_stmt = (
update(TemporaryCredit)
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(TemporaryCredit.reserved_balance)
- deducted_max_cost,
balance=col(TemporaryCredit.balance) - cost.total_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"total_cost": cost.total_msats,
"model": model,
},
)
else:
await session.refresh(key)
logger.info(
"Max cost payment finalized",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"new_balance": key.balance,
"model": model,
},
)
return cost.dict()
case CostData() as cost:
# If token-based pricing is enabled and base cost is 0, use token-based cost
# Otherwise, token cost is additional to the base cost
cost_difference = cost.total_msats - deducted_max_cost
total_cost_msats: int = math.ceil(cost.total_msats)
logger.info(
"Calculated token-based cost",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"token_cost": cost.total_msats,
"deducted_max_cost": deducted_max_cost,
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
if cost_difference == 0:
logger.debug(
"Finalizing with exact reserved cost",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
finalize_stmt = (
update(TemporaryCredit)
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(TemporaryCredit.reserved_balance)
- deducted_max_cost,
balance=col(TemporaryCredit.balance) - total_cost_msats,
)
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
await session.refresh(key)
return cost.dict()
# this should never happen why do we handle this???
if cost_difference > 0:
# Need to charge more than reserved, finalize by releasing reservation and charging total
logger.info(
"Additional charge required for token usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"additional_charge": cost_difference,
"current_balance": key.balance,
"sufficient_balance": key.balance >= cost_difference,
"model": model,
},
)
finalize_stmt = (
update(TemporaryCredit)
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(TemporaryCredit.reserved_balance)
- deducted_max_cost,
balance=col(TemporaryCredit.balance) - total_cost_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount:
cost.total_msats = total_cost_msats
await session.refresh(key)
logger.info(
"Finalized payment with additional charge",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": total_cost_msats,
"new_balance": key.balance,
"model": model,
},
)
else:
logger.warning(
"Failed to finalize additional charge (concurrent operation)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"attempted_charge": total_cost_msats,
"model": model,
},
)
else:
# Refund some of the base cost
refund = abs(cost_difference)
logger.info(
"Refunding excess payment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refund_amount": refund,
"current_balance": key.balance,
"model": model,
},
)
refund_stmt = (
update(TemporaryCredit)
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(TemporaryCredit.reserved_balance)
- deducted_max_cost,
balance=col(TemporaryCredit.balance) - total_cost_msats,
)
)
result = await session.exec(refund_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"total_cost": total_cost_msats,
"model": model,
},
)
# Still return the cost data even if we couldn't properly finalize
# The reservation was already made, so the user has paid
cost.total_msats = total_cost_msats
await session.refresh(key)
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
return cost.dict()
case CostDataError() as error:
logger.error(
"Cost calculation error during payment adjustment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
# Fallback return to satisfy type checker; execution should not reach here
return {
"base_msats": deducted_max_cost,
"input_msats": 0,
"output_msats": 0,
"total_msats": deducted_max_cost,
}
+9
View File
@@ -305,3 +305,12 @@ async def raw_send_to_lnurl(
quote_id=melt_quote_resp.quote,
)
return final_amount
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
from .wallet import get_wallet
wallet = await get_wallet(mint, unit)
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
return await raw_send_to_lnurl(wallet, proofs, address, unit)
+4
View File
@@ -110,6 +110,10 @@ async def _update_prices() -> None:
return
BTC_USD_PRICE = btc_price
SATS_USD_PRICE = btc_price / 100_000_000
logger.info(
"Updated BTC/USD price",
extra={"btc_usd": btc_price, "sats_usd": SATS_USD_PRICE},
)
def btc_usd_price() -> float:
+11 -44
View File
@@ -7,9 +7,9 @@ from cashu.wallet.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet
from sqlmodel import col, update
from .core import db, get_logger
from .core.settings import settings
from .payment.lnurl import raw_send_to_lnurl
from ..core import db, get_logger
from ..core.settings import settings
from ..payment.lnurl import raw_send_to_lnurl
logger = get_logger(__name__)
@@ -19,9 +19,7 @@ async def get_balance(unit: str) -> int:
return wallet.available_balance.amount
async def recieve_token(
token: str,
) -> tuple[int, str, str]: # amount, unit, mint_url
async def recieve_token(token: str) -> tuple[int, str, str]: # amount, unit, mint_url
token_obj = deserialize_token_from_string(token)
if len(token_obj.keysets) > 1:
raise ValueError("Multiple keysets per token currently not supported")
@@ -104,7 +102,7 @@ async def swap_to_primary_mint(
async def credit_balance(
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
cashu_token: str, credit: db.TemporaryCredit, session: db.AsyncSession
) -> int:
logger.info(
"credit_balance: Starting token redemption",
@@ -126,22 +124,22 @@ async def credit_balance(
logger.info(
"credit_balance: Updating balance",
extra={"old_balance": key.balance, "credit_amount": amount},
extra={"old_balance": credit.balance, "credit_amount": amount},
)
# Use atomic SQL UPDATE to prevent race conditions during concurrent topups
stmt = (
update(db.ApiKey)
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
.values(balance=(db.ApiKey.balance) + amount)
update(db.TemporaryCredit)
.where(col(db.TemporaryCredit.hashed_key) == credit.hashed_key)
.values(balance=(db.TemporaryCredit.balance) + amount)
)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
await session.refresh(key)
await session.refresh(credit)
logger.info(
"credit_balance: Balance updated successfully",
extra={"new_balance": key.balance},
extra={"new_balance": credit.balance},
)
logger.info(
@@ -351,34 +349,3 @@ async def periodic_payout() -> None:
f"Error sending payout: {type(e).__name__}",
extra={"error": str(e)},
)
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
wallet = await get_wallet(mint, unit)
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
return await raw_send_to_lnurl(wallet, proofs, address, unit)
# class Payment:
# """
# Stores all cashu payment related data
# """
# def __init__(self, token: str) -> None:
# self.initial_token = token
# amount, unit, mint_url = self.parse_token(token)
# self.amount = amount
# self.unit = unit
# self.mint_url = mint_url
# self.claimed_proofs = redeem_to_proofs(token)
# def parse_token(self, token: str) -> tuple[int, CurrencyUnit, str]:
# raise NotImplementedError
# def refund_full(self) -> None:
# raise NotImplementedError
# def refund_partial(self, amount: int) -> None:
# raise NotImplementedError
+72 -189
View File
@@ -5,24 +5,20 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from sqlmodel import select
from .algorithm import create_model_mappings
from .auth import pay_for_request, revert_pay_for_request, validate_bearer_key
from .auth import get_credit
from .core import get_logger
from .core.db import (
ApiKey,
AsyncSession,
ModelRow,
UpstreamProviderRow,
create_session,
get_session,
)
from .payment.helpers import (
calculate_discounted_max_cost,
check_token_balance,
create_error_response,
get_max_cost_for_model,
)
from .payment.models import Model
from .models.algorithm import create_model_mappings
from .models.models import Model
from .payment.cashu import pre_check_header_token_balance
from .payment.cost import calculate_discounted_max_cost, get_max_cost_for_model
from .payment.helpers import pay_for_request, revert_pay_for_request
from .upstream import BaseUpstreamProvider
from .upstream.helpers import init_upstreams
@@ -130,102 +126,89 @@ async def refresh_model_maps_periodically() -> None:
)
async def parse_request(request: Request) -> tuple[dict, dict, bytes]:
headers = dict(request.headers)
body_bytes = await request.body()
body_dict = parse_request_body_json(body_bytes)
return headers, body_dict, body_bytes
def ensure_authorization(headers: dict) -> None:
if "x-cashu" not in headers and "authorization" not in headers.keys():
# return create_error_response("unauthorized", "Unauthorized", 401, request=request)
raise HTTPException(status_code=401, detail="Unauthorized")
def log_request(request: Request, path: str) -> None:
logger.info(
"Received proxy request",
extra={
"method": request.method,
"path": path,
"client_host": request.client.host if request.client else "unknown",
"user_agent": request.headers.get("user-agent", "unknown")[:100],
},
)
def get_model_and_upstream(request_body: dict) -> tuple[Model, BaseUpstreamProvider]:
model_id = request_body.get("model", "unknown")
if not (model := get_model_instance(model_id)):
raise HTTPException(status_code=400, detail=f"Model '{model_id}' not found")
if not (upstream := get_provider_for_model(model_id)):
raise HTTPException(status_code=400, detail=f"No provider with '{model_id}'")
return model, upstream
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
request: Request,
path: str,
session: AsyncSession = Depends(get_session),
) -> Response | StreamingResponse:
headers = dict(request.headers)
headers, body_dict, body_bytes = await parse_request(request)
if "x-cashu" not in headers and "authorization" not in headers.keys():
return create_error_response(
"unauthorized", "Unauthorized", 401, request=request
)
ensure_authorization(headers)
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
request_body = await request.body()
request_body_dict = parse_request_body_json(request_body, path)
log_request(request, path)
if is_responses_api:
model_id = extract_model_from_responses_api_request(request_body_dict)
else:
model_id = request_body_dict.get("model", "unknown")
model, upstream = get_model_and_upstream(body_dict)
model_obj = get_model_instance(model_id)
if not model_obj:
return create_error_response(
"invalid_model", f"Model '{model_id}' not found", 400, request=request
)
upstream = get_provider_for_model(model_id)
if not upstream:
return create_error_response(
"invalid_model",
f"No provider found for model '{model_id}'",
400,
request=request,
)
_max_cost_for_model = await get_max_cost_for_model(
model=model_id, session=session, model_obj=model_obj
)
_max_cost_for_model = get_max_cost_for_model(model_obj=model)
max_cost_for_model = await calculate_discounted_max_cost(
_max_cost_for_model, request_body_dict, model_obj=model_obj
_max_cost_for_model, body_dict, model_obj=model
)
check_token_balance(headers, request_body_dict, max_cost_for_model)
pre_check_header_token_balance(headers, body_dict, max_cost_for_model)
if x_cashu := headers.get("x-cashu", None):
if is_responses_api:
return await upstream.handle_x_cashu_responses_api(
request, x_cashu, path, max_cost_for_model, model_obj
)
else:
return await upstream.handle_x_cashu(
request, x_cashu, path, max_cost_for_model, model_obj
)
return await upstream.handle_x_cashu(
request, x_cashu, path, max_cost_for_model, model
)
elif auth := headers.get("authorization", None):
key = await get_bearer_token_key(headers, path, session, auth)
key = await get_credit(auth, session)
else:
if request.method not in ["GET"]:
raise HTTPException(
status_code=401,
detail={
"error": {"type": "invalid_request_error", "code": "unauthorized"}
},
)
logger.debug("Processing unauthenticated GET request", extra={"path": path})
headers = upstream.prepare_headers(dict(request.headers))
return await upstream.forward_get_request(request, path, headers)
if request_body_dict:
# Only pay for request if we have request body data (for completions endpoints)
if body_dict:
await pay_for_request(key, max_cost_for_model, session)
# Prepare headers for upstream
headers = upstream.prepare_headers(dict(request.headers))
if is_responses_api:
response = await upstream.forward_responses_api_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
# Forward to upstream and handle response
response = await upstream.forward_request(
request,
path,
headers,
body_bytes,
key,
max_cost_for_model,
session,
model,
)
if response.status_code != 200:
await revert_pay_for_request(key, session, max_cost_for_model)
@@ -248,105 +231,7 @@ async def proxy(
return response
async def get_bearer_token_key(
headers: dict, path: str, session: AsyncSession, auth: str
) -> ApiKey:
"""Handle bearer token authentication proxy requests."""
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
refund_address = headers.get("Refund-LNURL", None)
key_expiry_time = headers.get("Key-Expiry-Time", None)
logger.debug(
"Processing bearer token",
extra={
"path": path,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
# Validate key_expiry_time header
if key_expiry_time:
try:
key_expiry_time = int(key_expiry_time) # type: ignore
logger.debug(
"Key expiry time validated",
extra={"expiry_time": key_expiry_time, "path": path},
)
except ValueError:
logger.error(
"Invalid Key-Expiry-Time header",
extra={"key_expiry_time": key_expiry_time, "path": path},
)
raise HTTPException(
status_code=400,
detail="Invalid Key-Expiry-Time: must be a valid Unix timestamp",
)
if not refund_address:
logger.error(
"Missing Refund-LNURL header with Key-Expiry-Time",
extra={"path": path, "expiry_time": key_expiry_time},
)
raise HTTPException(
status_code=400,
detail="Error: Refund-LNURL header required when using Key-Expiry-Time",
)
else:
key_expiry_time = None
try:
key = await validate_bearer_key(
bearer_key,
session,
refund_address,
key_expiry_time, # type: ignore
)
logger.info(
"Bearer token validated successfully",
extra={
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
},
)
return key
except Exception as e:
logger.error(
"Bearer token validation failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
raise
def extract_model_from_responses_api_request(request_body_dict: dict[str, Any]) -> str:
if model := request_body_dict.get("model"):
return model
if input_data := request_body_dict.get("input"):
if isinstance(input_data, dict) and (model := input_data.get("model")):
return model
if request_body_dict.get("messages"):
return "unknown"
logger.warning(
"No model found in Responses API request",
extra={"body_keys": list(request_body_dict.keys())},
)
return "unknown"
def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
def parse_request_body_json(request_body: bytes) -> dict[str, Any]:
request_body_dict = {}
if request_body:
try:
@@ -366,7 +251,6 @@ def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
logger.debug(
"Request body parsed",
extra={
"path": path,
"body_keys": list(request_body_dict.keys()),
"model": request_body_dict.get("model", "not_specified"),
},
@@ -376,7 +260,6 @@ def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
"Invalid JSON in request body",
extra={
"error": str(e),
"path": path,
"body_preview": request_body[:200].decode(errors="ignore")
if request_body
else "empty",
-4
View File
@@ -2,28 +2,24 @@ from .anthropic import AnthropicUpstreamProvider
from .azure import AzureUpstreamProvider
from .base import BaseUpstreamProvider
from .fireworks import FireworksUpstreamProvider
from .gemini import GeminiUpstreamProvider
from .generic import GenericUpstreamProvider
from .groq import GroqUpstreamProvider
from .ollama import OllamaUpstreamProvider
from .openai import OpenAIUpstreamProvider
from .openrouter import OpenRouterUpstreamProvider
from .perplexity import PerplexityUpstreamProvider
from .ppqai import PPQAIUpstreamProvider
from .xai import XAIUpstreamProvider
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
AnthropicUpstreamProvider,
AzureUpstreamProvider,
FireworksUpstreamProvider,
GeminiUpstreamProvider,
GenericUpstreamProvider,
GroqUpstreamProvider,
OllamaUpstreamProvider,
OpenAIUpstreamProvider,
OpenRouterUpstreamProvider,
PerplexityUpstreamProvider,
PPQAIUpstreamProvider,
XAIUpstreamProvider,
]
"""List of all upstream classes"""
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from ..models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
+1071 -594
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
from .gemini import GeminiClient
__all__ = ["GeminiClient"]
-40
View File
@@ -1,40 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator
class BaseAPIClient(ABC):
"""Base class for AI provider API clients."""
def __init__(self, api_key: str, base_url: str | None = None):
self.api_key = api_key
self.base_url = base_url
@abstractmethod
async def generate_content(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Generate content non-streaming."""
pass
@abstractmethod
def generate_content_stream(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
pass
@abstractmethod
async def list_models(self) -> list[dict[str, Any]]:
"""List available models."""
pass
-88
View File
@@ -1,88 +0,0 @@
from __future__ import annotations
from typing import Any, AsyncGenerator
from openai import AsyncOpenAI
from .base import BaseAPIClient
class GeminiClient(BaseAPIClient):
"""Gemini API client using OpenAI compatibility layer."""
def __init__(self, api_key: str, base_url: str | None = None):
super().__init__(api_key, base_url)
self.client = AsyncOpenAI(
api_key=api_key,
base_url=base_url
or "https://generativelanguage.googleapis.com/v1beta/openai/",
)
async def generate_content(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
from openai import NOT_GIVEN
response = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
)
return response.model_dump()
async def generate_content_stream(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
from openai import NOT_GIVEN
usage_callback = kwargs.get("usage_callback")
completion_callback = kwargs.get("completion_callback")
stream = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
stream=True,
stream_options={"include_usage": True},
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
)
final_usage = None
async for chunk in stream:
chunk_data = chunk.model_dump()
if chunk.usage:
final_usage = chunk.usage.model_dump()
if usage_callback:
usage_callback(final_usage)
yield chunk_data
if completion_callback:
await completion_callback(model, final_usage)
async def list_models(self) -> list[dict[str, Any]]:
"""List available Gemini models."""
try:
response = await self.client.models.list()
return [model.model_dump() for model in response.data]
except Exception as e:
from ...core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"Failed to list Gemini models: {e}")
return []
-283
View File
@@ -1,283 +0,0 @@
from __future__ import annotations
import json
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from .base import BaseUpstreamProvider
from .clients.gemini import GeminiClient
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
logger = get_logger(__name__)
class GeminiUpstreamProvider(BaseUpstreamProvider):
provider_type = "gemini"
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
platform_url = "https://aistudio.google.com/app/apikey"
def __init__(
self,
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
api_key: str = "",
provider_fee: float = 1.01,
):
super().__init__(
api_key=api_key,
provider_fee=provider_fee,
base_url=base_url,
)
self._client: GeminiClient | None = None
@property
def client(self) -> GeminiClient:
"""Get or create the Gemini API client."""
if self._client is None:
self._client = GeminiClient(api_key=self.api_key)
return self._client
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "GeminiUpstreamProvider":
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Google Gemini",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
return model_id.removeprefix("gemini/")
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
# Remove provider prefix from model ID for Gemini API
if "/" in model_obj.id:
model_obj.id = model_obj.id.split("/", 1)[1]
if not path.startswith("chat/completions"):
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
if not request_body:
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
try:
openai_data = json.loads(request_body)
messages = openai_data.get("messages", [])
temperature = openai_data.get("temperature")
max_tokens = openai_data.get("max_tokens")
top_p = openai_data.get("top_p")
is_streaming = openai_data.get("stream", False)
logger.info(
"Processing Gemini request with client abstraction",
extra={
"model": model_obj.id,
"is_streaming": is_streaming,
"message_count": len(messages),
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming:
final_usage_data: dict | None = None
def usage_callback(usage_data: dict[str, Any]) -> None:
"""Callback to capture usage data during streaming"""
nonlocal final_usage_data
final_usage_data = usage_data
async def completion_callback(
model: str, usage_data: dict[str, Any] | None
) -> None:
"""Callback to handle payment when streaming completes"""
nonlocal final_usage_data
if usage_data:
final_usage_data = usage_data
payment_data = {
"model": model,
"usage": final_usage_data,
}
from ..auth import adjust_payment_for_tokens
from ..core.db import create_session
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
payment_data,
new_session,
max_cost_for_model,
)
logger.info(
"Gemini streaming payment finalized",
extra={
"cost_data": cost_data,
"usage_data": final_usage_data,
"key_hash": key.hashed_key[:8] + "...",
},
)
except Exception as cost_error:
logger.error(
"Error finalizing Gemini streaming payment",
extra={
"error": str(cost_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
response_generator = self.client.generate_content_stream(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
usage_callback=usage_callback,
completion_callback=completion_callback,
)
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
try:
async for chunk in response_generator:
sse_data = f"data: {json.dumps(chunk)}\n\n"
yield sse_data.encode()
except Exception as e:
logger.error(
"Error in Gemini streaming response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
return StreamingResponse(
stream_with_cost(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
else:
openai_format_response = await self.client.generate_content(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
)
from ..auth import adjust_payment_for_tokens
cost_data = await adjust_payment_for_tokens(
key, openai_format_response, session, max_cost_for_model
)
openai_format_response["cost"] = cost_data
logger.info(
"Gemini non-streaming payment completed",
extra={
"cost_data": cost_data,
"model": model_obj.id,
"key_hash": key.hashed_key[:8] + "...",
},
)
return Response(
content=json.dumps(openai_format_response),
media_type="application/json",
headers={"Cache-Control": "no-cache"},
)
except Exception as e:
logger.error(
"Error in Gemini forward_request",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
},
)
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
async def _fetch_provider_models(self) -> dict:
"""Fetch models from Gemini API."""
try:
models_data = await self.client.list_models()
for model in models_data:
if "id" in model and model["id"].startswith("models/"):
model["id"] = model["id"].removeprefix("models/")
return {"data": models_data}
except Exception as e:
logger.error(
f"Failed to fetch models from Gemini API: {e}",
extra={
"error": str(e),
"error_type": type(e).__name__,
"base_url": self.base_url,
},
)
return {"data": []}
@@ -1,583 +0,0 @@
"""X-Cashu payment request handlers for different API types."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Callable
import httpx
from fastapi import BackgroundTasks, Request
from fastapi.responses import Response, StreamingResponse
from ...core import get_logger
from ...wallet import send_token
from ..payment.cashu_handler import (
CashuTokenError,
create_cashu_error_response,
validate_and_redeem_token,
)
from ..processing.http_client import HttpForwarder
from ..processing.response_handler import ChatCompletionProcessor, ResponsesApiProcessor
if TYPE_CHECKING:
from ...payment.models import Model
logger = get_logger(__name__)
class BaseCashuHandler:
"""Base handler for X-Cashu payment requests."""
def __init__(self, base_url: str):
self.base_url = base_url
self.http_forwarder = HttpForwarder(base_url)
async def send_refund(self, amount: int, unit: str, mint: str | None = None) -> str:
"""Create and send a refund token to the user."""
logger.debug(
"Creating refund token",
extra={"amount": amount, "unit": unit, "mint": mint},
)
max_retries = 3
last_exception = None
for attempt in range(max_retries):
try:
refund_token = await send_token(amount, unit=unit, mint_url=mint)
logger.info(
"Refund token created successfully",
extra={
"amount": amount,
"unit": unit,
"mint": mint,
"attempt": attempt + 1,
"token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
return refund_token
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
"Refund token creation failed, retrying",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
else:
logger.error(
"Failed to create refund token after all retries",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
raise Exception(
f"failed to create refund after {max_retries} attempts: {str(last_exception)}"
)
def _calculate_refund_amount(self, amount: int, unit: str, cost_msats: int) -> int:
"""Calculate refund amount based on unit and cost."""
if unit == "msat":
return amount - cost_msats
elif unit == "sat":
return amount - (cost_msats + 999) // 1000
else:
raise ValueError(f"Invalid unit: {unit}")
async def _handle_upstream_error(
self,
response: httpx.Response,
amount: int,
unit: str,
mint: str | None,
api_type: str = "API",
) -> Response:
"""Handle upstream service errors with refund."""
logger.warning(
f"Upstream {api_type} request failed, processing refund",
extra={
"status_code": response.status_code,
"amount": amount,
"unit": unit,
},
)
refund_token = await self.send_refund(amount - 60, unit, mint)
logger.info(
f"Refund processed for failed upstream {api_type} request",
extra={
"status_code": response.status_code,
"refund_amount": amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
error_response = Response(
content=json.dumps(
{
"error": {
"message": f"Error forwarding {api_type} request to upstream",
"type": "upstream_error",
"code": response.status_code,
"refund_token": refund_token,
}
}
),
status_code=response.status_code,
media_type="application/json",
)
error_response.headers["X-Cashu"] = refund_token
return error_response
class ChatCompletionCashuHandler(BaseCashuHandler):
"""Handler for X-Cashu paid chat completion requests."""
async def handle_request(
self,
request: Request,
x_cashu_token: str,
path: str,
headers: dict,
max_cost_for_model: int,
model_obj: Model,
prepare_request_body_func: Callable,
get_x_cashu_cost_func: Callable,
query_params_func: Callable,
) -> Response | StreamingResponse:
"""Handle chat completion request with X-Cashu payment."""
try:
amount, unit, mint = await validate_and_redeem_token(x_cashu_token, request)
# Forward request to upstream
request_body = await request.body()
response = await self.http_forwarder.forward_request(
request=request,
path=path,
headers=headers,
request_body=request_body,
query_params=query_params_func(path, request.query_params),
transform_body_func=prepare_request_body_func,
model_obj=model_obj,
)
# Handle upstream errors
if response.status_code != 200:
return await self._handle_upstream_error(
response, amount, unit, mint, "chat completion"
)
# Process chat completion response
if path.endswith("chat/completions"):
result = await self._handle_chat_completion_response(
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
result.background = background_tasks
return result
# Default streaming response for other endpoints
return self._create_default_streaming_response(response)
except CashuTokenError as e:
return create_cashu_error_response(e, request, x_cashu_token)
async def _handle_chat_completion_response(
self,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> Response | StreamingResponse:
"""Handle chat completion response processing."""
content = await response.aread()
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
is_streaming = ChatCompletionProcessor.is_streaming_response(content_str)
if is_streaming:
return await self._handle_streaming_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
else:
return await self._handle_non_streaming_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
async def _handle_streaming_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> StreamingResponse:
"""Handle streaming chat completion response with refund calculation."""
response_headers = ChatCompletionProcessor.clean_response_headers(
dict(response.headers)
)
usage_data, model = ChatCompletionProcessor.extract_usage_from_streaming(
content_str
)
if usage_data and model:
try:
response_data = {"usage": usage_data, "model": model}
cost_data = await get_x_cashu_cost_func(
response_data, max_cost_for_model
)
if cost_data:
refund_amount = self._calculate_refund_amount(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(refund_amount, unit, mint)
response_headers["X-Cashu"] = refund_token
except Exception as e:
logger.error(
"Error calculating cost for streaming response",
extra={"error": str(e), "error_type": type(e).__name__},
)
lines = content_str.strip().split("\n")
return StreamingResponse(
ChatCompletionProcessor.create_streaming_generator(lines),
status_code=response.status_code,
headers=response_headers,
media_type="text/plain",
)
async def _handle_non_streaming_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> Response:
"""Handle non-streaming chat completion response with refund calculation."""
response_headers = ChatCompletionProcessor.clean_response_headers(
dict(response.headers)
)
try:
response_json = json.loads(content_str)
cost_data = await get_x_cashu_cost_func(response_json, max_cost_for_model)
if cost_data:
refund_amount = self._calculate_refund_amount(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(refund_amount, unit, mint)
response_headers["X-Cashu"] = refund_token
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError:
# Emergency refund on parse error
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response_headers["X-Cashu"] = refund_token
logger.warning(
"Emergency refund issued due to JSON parse error",
extra={
"original_amount": amount,
"refund_amount": emergency_refund,
"deduction": 60,
},
)
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
def _create_default_streaming_response(
self, response: httpx.Response
) -> StreamingResponse:
"""Create default streaming response for non-chat endpoints."""
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
class ResponsesApiCashuHandler(BaseCashuHandler):
"""Handler for X-Cashu paid Responses API requests."""
async def handle_request(
self,
request: Request,
x_cashu_token: str,
path: str,
headers: dict,
max_cost_for_model: int,
model_obj: Model,
prepare_responses_api_request_body_func: Callable,
get_x_cashu_cost_func: Callable,
query_params_func: Callable,
) -> Response | StreamingResponse:
"""Handle Responses API request with X-Cashu payment."""
try:
amount, unit, mint = await validate_and_redeem_token(x_cashu_token, request)
# Forward request to upstream
request_body = await request.body()
response = await self.http_forwarder.forward_request(
request=request,
path=path,
headers=headers,
request_body=request_body,
query_params=query_params_func(path, request.query_params),
transform_body_func=prepare_responses_api_request_body_func,
model_obj=model_obj,
)
# Handle upstream errors
if response.status_code != 200:
return await self._handle_upstream_error(
response, amount, unit, mint, "Responses API"
)
# Process Responses API response
if path.startswith("responses"):
result = await self._handle_responses_api_completion(
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
result.background = background_tasks
return result
# Default streaming response for other endpoints
return self._create_default_streaming_response(response)
except CashuTokenError as e:
return create_cashu_error_response(e, request, x_cashu_token)
async def _handle_responses_api_completion(
self,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> Response | StreamingResponse:
"""Handle Responses API completion response processing."""
content = await response.aread()
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
is_streaming = ResponsesApiProcessor.is_streaming_response(content_str)
if is_streaming:
return await self._handle_streaming_responses_api_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
else:
return await self._handle_non_streaming_responses_api_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
async def _handle_streaming_responses_api_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> StreamingResponse:
"""Handle streaming Responses API response with refund calculation."""
response_headers = ResponsesApiProcessor.clean_response_headers(
dict(response.headers)
)
# Extract usage data (ignoring reasoning tokens as per requirement)
usage_data, model = ResponsesApiProcessor.extract_usage_with_reasoning_tokens(
content_str
)
if usage_data and model:
try:
response_data = {"usage": usage_data, "model": model}
cost_data = await get_x_cashu_cost_func(
response_data, max_cost_for_model
)
if cost_data:
refund_amount = self._calculate_refund_amount(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(refund_amount, unit, mint)
response_headers["X-Cashu"] = refund_token
except Exception as e:
logger.error(
"Error calculating cost for streaming Responses API response",
extra={"error": str(e), "error_type": type(e).__name__},
)
lines = content_str.strip().split("\n")
return StreamingResponse(
ResponsesApiProcessor.create_streaming_generator(lines),
status_code=response.status_code,
headers=response_headers,
media_type="text/plain",
)
async def _handle_non_streaming_responses_api_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> Response:
"""Handle non-streaming Responses API response with refund calculation."""
response_headers = ResponsesApiProcessor.clean_response_headers(
dict(response.headers)
)
try:
response_json = json.loads(content_str)
cost_data = await get_x_cashu_cost_func(response_json, max_cost_for_model)
if cost_data:
refund_amount = self._calculate_refund_amount(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(refund_amount, unit, mint)
response_headers["X-Cashu"] = refund_token
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError:
# Emergency refund on parse error
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response_headers["X-Cashu"] = refund_token
logger.warning(
"Emergency refund issued for Responses API due to JSON parse error",
extra={
"original_amount": amount,
"refund_amount": emergency_refund,
"deduction": 60,
},
)
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
def _create_default_streaming_response(
self, response: httpx.Response
) -> StreamingResponse:
"""Create default streaming response for non-responses endpoints."""
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
+4 -4
View File
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
from ..core import get_logger
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
from ..payment.models import Model
from ..models import Model
from .base import BaseUpstreamProvider
logger = get_logger(__name__)
@@ -82,7 +82,7 @@ async def get_all_models_with_overrides(
"""
from sqlmodel import select
from ..payment.models import _row_to_model
from ..models.crud import row_to_model
async with create_session() as session:
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
@@ -108,7 +108,7 @@ async def get_all_models_with_overrides(
for model in upstream.get_cached_models():
if model.id in overrides_by_id:
override_row, provider_fee = overrides_by_id[model.id]
all_models[model.id] = _row_to_model(
all_models[model.id] = row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
elif model.enabled:
@@ -193,7 +193,7 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.debug(
logger.info(
f"Initialized {provider_row.provider_type} provider",
extra={
"base_url": provider_row.base_url,
+7 -7
View File
@@ -9,8 +9,8 @@ from fastapi.responses import Response, StreamingResponse
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..payment.models import Model
from ..core.db import AsyncSession, TemporaryCredit, UpstreamProviderRow
from ..models import Model
from ..core.logging import get_logger
@@ -73,7 +73,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
key: TemporaryCredit,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
@@ -102,7 +102,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
async def fetch_models(self) -> list[Model]:
"""Fetch models from Ollama API using /api/tags endpoint."""
from ..payment.models import Architecture, Model, Pricing, TopProvider
from ..models.models import Architecture, Model, Pricing, TopProvider
try:
async with httpx.AsyncClient(timeout=30.0) as client:
@@ -199,7 +199,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
async def refresh_models_cache(self) -> None:
"""Refresh the in-memory models cache from upstream API."""
try:
from ..payment.models import _update_model_sats_pricing
from ..models.models import _update_model_sats_pricing
from ..payment.price import sats_usd_price
models = await self.fetch_models()
@@ -252,7 +252,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
Returns:
Model with provider fee applied to pricing and max costs calculated
"""
from ..payment.models import Model, Pricing, _calculate_usd_max_costs
from ..models.models import Model, Pricing, calculate_usd_max_costs
adjusted_pricing = Pricing.parse_obj(
{k: v * self.provider_fee for k, v in model.pricing.dict().items()}
@@ -278,7 +278,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
adjusted_pricing.max_prompt_cost,
adjusted_pricing.max_completion_cost,
adjusted_pricing.max_cost,
) = _calculate_usd_max_costs(temp_model)
) = calculate_usd_max_costs(temp_model)
return Model(
id=model.id,
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from ..models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
+1 -27
View File
@@ -1,8 +1,6 @@
from typing import TYPE_CHECKING
import httpx
from ..payment.models import Model, async_fetch_openrouter_models
from ..models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
@@ -44,33 +42,9 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
"can_show_balance": True,
}
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.
Returns:
Float representing the balance amount (in credits/USD), or None if unavailable.
"""
url = f"{self.base_url}/credits"
headers = {"Authorization": f"Bearer {self.api_key}"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
credits_data = data.get("data", {})
total_credits = float(credits_data.get("total_credits", 0.0))
total_usage = float(credits_data.get("total_usage", 0.0))
return total_credits - total_usage
except Exception:
return None
-111
View File
@@ -1,111 +0,0 @@
"""X-Cashu payment handling utilities."""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import Request
from fastapi.responses import Response
from ...core import get_logger
from ...payment.helpers import create_error_response
from ...wallet import recieve_token
if TYPE_CHECKING:
pass
logger = get_logger(__name__)
class CashuTokenError(Exception):
"""Exception raised for X-Cashu token processing errors."""
def __init__(self, error_type: str, message: str, status_code: int = 400):
self.error_type = error_type
self.message = message
self.status_code = status_code
super().__init__(message)
async def validate_and_redeem_token(
x_cashu_token: str, request: Request
) -> tuple[int, str, str | None]:
"""Validate and redeem X-Cashu token.
Args:
x_cashu_token: The X-Cashu token to validate and redeem
request: Original FastAPI request (for error responses)
Returns:
Tuple of (amount, unit, mint_url)
Raises:
CashuTokenError: If token validation or redemption fails
"""
logger.info(
"Processing X-Cashu token redemption",
extra={
"token_preview": x_cashu_token[:20] + "..."
if len(x_cashu_token) > 20
else x_cashu_token,
},
)
try:
amount, unit, mint = await recieve_token(x_cashu_token)
logger.info(
"X-Cashu token redeemed successfully",
extra={"amount": amount, "unit": unit, "mint": mint},
)
return amount, unit, mint
except Exception as e:
error_message = str(e)
logger.error(
"X-Cashu token redemption failed",
extra={
"error": error_message,
"error_type": type(e).__name__,
},
)
# Determine specific error type
if "already spent" in error_message.lower():
raise CashuTokenError(
"token_already_spent",
"The provided CASHU token has already been spent",
400,
)
elif "invalid token" in error_message.lower():
raise CashuTokenError(
"invalid_token", "The provided CASHU token is invalid", 400
)
elif "mint error" in error_message.lower():
raise CashuTokenError(
"mint_error", f"CASHU mint error: {error_message}", 422
)
else:
raise CashuTokenError(
"cashu_error", f"CASHU token processing failed: {error_message}", 400
)
def create_cashu_error_response(
error: CashuTokenError, request: Request, x_cashu_token: str
) -> Response:
"""Create error response for X-Cashu token errors.
Args:
error: The CashuTokenError that occurred
request: Original FastAPI request
x_cashu_token: The token that caused the error
Returns:
Error response with appropriate status code and message
"""
return create_error_response(
error.error_type,
error.message,
error.status_code,
request=request,
token=x_cashu_token,
)
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from ..models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
-401
View File
@@ -1,401 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from pydantic import BaseModel
from ..core.logging import get_logger
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
from .base import BaseUpstreamProvider, TopupData
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
logger = get_logger(__name__)
class PPQAIModelPricing(BaseModel):
ui: dict[str, float]
api: dict[str, float]
class PPQAIModel(BaseModel):
id: str
provider: str
name: str
created_at: int
context_length: int
pricing: PPQAIModelPricing
popular: bool
class PPQAIUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider for PPQ.AI API with Lightning Network top-up support."""
provider_type = "ppqai"
default_base_url = "https://api.ppq.ai"
platform_url = "https://ppq.ai/api-docs"
IGNORED_MODEL_IDS: list[str] = ["auto"]
def __init__(self, api_key: str, provider_fee: float = 1.0):
super().__init__(
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "PPQAIUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "PPQ.AI",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
"can_create_account": True,
"can_topup": True,
"can_show_balance": True,
}
def transform_model_name(self, model_id: str) -> str:
return model_id
@classmethod
async def create_account_static(cls) -> dict[str, object]:
"""Create a new PPQ.AI account without requiring an instance.
Returns:
Dict containing 'credit_id' and 'api_key' for the new account.
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{cls.default_base_url}/accounts/create"
logger.info("Creating new PPQ.AI account", extra={"url": url})
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url)
response.raise_for_status()
account_data = response.json()
logger.info(
"Successfully created PPQ.AI account",
extra={
"credit_id": account_data.get("credit_id"),
"has_api_key": bool(account_data.get("api_key")),
},
)
return account_data
async def fetch_models(self) -> list[Model]:
"""Fetch models from PPQ.AI API."""
url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
models_data = data.get("data", [])
or_models = [
Model(**model) # type: ignore
for model in await async_fetch_openrouter_models()
]
models = []
for model_data in models_data:
try:
ppqai_model = PPQAIModel.parse_obj(model_data)
if ppqai_model.id in self.IGNORED_MODEL_IDS:
continue
or_model = next(
(
model
for model in or_models
if (model.id == ppqai_model.id)
or (model.id.split("/")[-1] == ppqai_model.id)
or (model.id == ppqai_model.id.split("/")[-1])
),
None,
)
if or_model:
if input_price := ppqai_model.pricing.api.get(
"input_per_1M"
):
or_model.pricing.prompt = input_price / 1_000_000
if output_price := ppqai_model.pricing.api.get(
"output_per_1M"
):
or_model.pricing.completion = output_price / 1_000_000
if cl := ppqai_model.context_length:
or_model.context_length = cl
models.append(or_model)
else:
input_price = ppqai_model.pricing.api.get(
"input_per_1M", 0.0
)
output_price = ppqai_model.pricing.api.get(
"output_per_1M", 0.0
)
models.append(
Model(
id=ppqai_model.id,
name=ppqai_model.name,
created=ppqai_model.created_at // 1000,
description=f"{ppqai_model.provider} model",
context_length=ppqai_model.context_length,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="Unknown",
instruct_type=None,
),
pricing=Pricing(
prompt=input_price / 1_000_000,
completion=output_price / 1_000_000,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
),
)
)
except Exception as e:
logger.warning(
"Failed to parse PPQ.AI model",
extra={
"model_id": model_data.get("id", "unknown"),
"error": str(e),
"error_type": type(e).__name__,
},
)
return models
except Exception as e:
logger.error(
"Error fetching models from PPQ.AI",
extra={"error": str(e), "error_type": type(e).__name__},
)
return []
async def create_account(self) -> dict[str, object]:
"""Create a new PPQ.AI account.
Returns:
Dict containing 'credit_id' and 'api_key' for the new account.
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{self.base_url}/accounts/create"
logger.info("Creating new PPQ.AI account", extra={"url": url})
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url)
response.raise_for_status()
account_data = response.json()
logger.info(
"Successfully created PPQ.AI account",
extra={
"credit_id": account_data.get("credit_id"),
"has_api_key": bool(account_data.get("api_key")),
},
)
return account_data
async def create_lightning_topup(
self, amount: int, currency: str
) -> dict[str, object]:
"""Create a Lightning Network top-up invoice for this account.
Args:
amount: Amount to top up (in the specified currency)
currency: Currency for the top-up (default: "USD")
Returns:
Dict containing invoice details including 'invoice_id', 'payment_request', etc.
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{self.base_url}/topup/create/btc-lightning"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {"amount": amount, "currency": currency}
logger.info(
"Creating Lightning top-up invoice",
extra={"url": url, "amount": amount, "currency": currency},
)
async with httpx.AsyncClient(timeout=30.0) as client:
print(f"Payload: {payload}", "sending to", url)
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
invoice_data = response.json()
logger.info(
"Successfully created Lightning top-up invoice",
extra={
"invoice_id": invoice_data.get("invoice_id"),
"amount": amount,
"currency": currency,
},
)
return invoice_data
async def check_topup_status(self, invoice_id: str) -> bool:
"""Check the status of a Lightning top-up invoice.
Args:
invoice_id: The invoice ID to check
Returns:
True if the invoice is paid (status == "Settled"), False otherwise
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{self.base_url}/topup/status/{invoice_id}"
headers = {"Authorization": f"Bearer {self.api_key}"}
logger.debug(
"Checking Lightning top-up status",
extra={"url": url, "invoice_id": invoice_id},
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
status_data = response.json()
is_paid = status_data.get("status") == "Settled"
logger.debug(
"Retrieved Lightning top-up status",
extra={
"invoice_id": invoice_id,
"status": status_data.get("status"),
"is_paid": is_paid,
},
)
return is_paid
async def initiate_topup(self, amount: int) -> TopupData:
"""Initiate a Lightning Network top-up for the PPQ.AI account.
Args:
amount: Amount in currency units to top up (will be sent to PPQ.AI API)
Returns:
TopupData with standardized invoice information
Raises:
httpx.HTTPStatusError: If the API request fails
"""
ppq_response = await self.create_lightning_topup(amount, "USD")
logger.info(
"PPQ.AI top-up response",
extra={
"ppq_response": ppq_response,
"invoice_id": ppq_response.get("invoice_id"),
"has_lightning_invoice": "lightning_invoice" in ppq_response,
},
)
expires_at_value = ppq_response.get("expires_at")
checkout_url_value = ppq_response.get("checkout_url")
topup_data = TopupData(
invoice_id=str(ppq_response["invoice_id"]),
payment_request=str(ppq_response["lightning_invoice"]),
amount=int(ppq_response["amount"])
if isinstance(ppq_response["amount"], (int, float, str))
else 0,
currency=str(ppq_response["currency"]),
expires_at=int(expires_at_value)
if isinstance(expires_at_value, (int, float, str))
and expires_at_value is not None
else None,
checkout_url=str(checkout_url_value)
if checkout_url_value is not None
else None,
)
logger.info(
"Created TopupData",
extra={
"invoice_id": topup_data.invoice_id,
"payment_request_length": len(topup_data.payment_request),
"amount": topup_data.amount,
},
)
return topup_data
async def get_balance(self) -> float | None:
"""Get the current account balance from PPQ.AI.
Returns:
Float representing the balance amount (in USD), or None if unavailable.
Raises:
httpx.HTTPStatusError: If the API request fails
"""
data = await self.check_balance()
balance = data.get("balance")
if isinstance(balance, (int, float)):
return float(balance)
return None
async def check_balance(self) -> dict[str, object]:
"""Check the account balance for this PPQ.AI account.
Returns:
Dict containing balance information
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{self.base_url}/credits/balance"
headers = {"Authorization": f"Bearer {self.api_key}"}
logger.debug("Checking PPQ.AI account balance", extra={"url": url})
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, headers=headers, json={})
response.raise_for_status()
balance_data = response.json()
logger.debug(
"Retrieved PPQ.AI account balance",
extra={"balance": balance_data.get("balance")},
)
return balance_data
-200
View File
@@ -1,200 +0,0 @@
"""HTTP client utilities for upstream requests."""
from __future__ import annotations
import traceback
from typing import TYPE_CHECKING, Callable, Mapping
import httpx
from fastapi import BackgroundTasks, Request
from fastapi.responses import StreamingResponse
from ...core import get_logger
if TYPE_CHECKING:
from ...payment.models import Model
logger = get_logger(__name__)
class HttpForwarder:
"""Handles HTTP request forwarding to upstream services."""
def __init__(self, base_url: str):
self.base_url = base_url
def _prepare_path(self, path: str) -> str:
"""Prepare path by removing v1/ prefix if present."""
if path.startswith("v1/"):
path = path.replace("v1/", "")
return path
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
query_params: Mapping[str, str] | None,
transform_body_func: Callable | None = None,
model_obj: Model | None = None,
) -> httpx.Response:
"""Forward HTTP request to upstream service.
Args:
request: Original FastAPI request
path: Request path
headers: Prepared headers for upstream
request_body: Request body bytes, if any
query_params: Query parameters for the request
transform_body_func: Optional function to transform request body
model_obj: Model object for request body transformation
Returns:
Response from upstream service
Raises:
httpx.RequestError: If request fails
Exception: For other unexpected errors
"""
path = self._prepare_path(path)
url = f"{self.base_url}/{path}"
# Transform body if function provided
transformed_body = request_body
if transform_body_func and request_body and model_obj:
transformed_body = transform_body_func(request_body, model_obj)
logger.info(
"Forwarding request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"has_request_body": request_body is not None,
},
)
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
)
try:
if transformed_body is not None:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=transformed_body,
params=query_params,
),
stream=True,
)
else:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=query_params,
),
stream=True,
)
logger.info(
"Received upstream response",
extra={
"status_code": response.status_code,
"path": path,
"content_type": response.headers.get("content-type", "unknown"),
},
)
return response
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
error_details = str(exc)
logger.error(
"HTTP request error to upstream",
extra={
"error_type": error_type,
"error_details": error_details,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
},
)
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
error_message = f"Error connecting to upstream service: {error_type}"
raise httpx.RequestError(error_message) from exc
except Exception as exc:
await client.aclose()
tb = traceback.format_exc()
logger.error(
"Unexpected error in upstream forwarding",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"traceback": tb,
},
)
raise
class StreamingResponseWrapper:
"""Wrapper for creating streaming responses with proper cleanup."""
@staticmethod
def create_streaming_response(
response: httpx.Response,
client: httpx.AsyncClient,
) -> StreamingResponse:
"""Create a streaming response with background cleanup tasks.
Args:
response: httpx response to stream
client: httpx client to clean up
Returns:
StreamingResponse with background cleanup
"""
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
logger.debug(
"Creating streaming response",
extra={
"status_code": response.status_code,
"content_type": response.headers.get("content-type", "unknown"),
},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
@@ -1,170 +0,0 @@
"""Response processing utilities for different API types."""
from __future__ import annotations
import json
import re
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
from ...core import get_logger
if TYPE_CHECKING:
pass
logger = get_logger(__name__)
class ResponseProcessor:
"""Processes responses from upstream services."""
@staticmethod
def is_streaming_response(content_str: str) -> bool:
"""Determine if response content indicates streaming.
Args:
content_str: Response content as string
Returns:
True if response is streaming, False otherwise
"""
return content_str.startswith("data:") or "data:" in content_str
@staticmethod
def extract_usage_from_streaming(
content_str: str,
) -> tuple[dict | None, str | None]:
"""Extract usage data and model from streaming response content.
Args:
content_str: Streaming response content as string
Returns:
Tuple of (usage_data, model) or (None, None) if not found
"""
usage_data = None
model = None
lines = content_str.strip().split("\n")
for line in lines:
if line.startswith("data: "):
try:
data_json = json.loads(line[6:])
if "usage" in data_json:
usage_data = data_json["usage"]
model = data_json.get("model")
elif "model" in data_json and not model:
model = data_json["model"]
except json.JSONDecodeError:
continue
return usage_data, model
@staticmethod
def clean_response_headers(headers: dict) -> dict:
"""Clean response headers by removing encoding-related headers.
Args:
headers: Original response headers
Returns:
Cleaned headers dict
"""
cleaned_headers = dict(headers)
headers_to_remove = ["transfer-encoding", "content-encoding", "content-length"]
for header in headers_to_remove:
cleaned_headers.pop(header, None)
return cleaned_headers
@staticmethod
async def create_streaming_generator(
lines: list[str],
) -> AsyncGenerator[bytes, None]:
"""Create async generator for streaming response lines.
Args:
lines: List of response lines to stream
Yields:
Encoded response lines
"""
for line in lines:
yield (line + "\n").encode("utf-8")
class ChatCompletionProcessor(ResponseProcessor):
"""Processor specifically for chat completion responses."""
@staticmethod
def extract_model_from_chunks(stored_chunks: list[bytes]) -> str | None:
"""Extract model name from stored response chunks.
Args:
stored_chunks: List of response chunks from streaming
Returns:
Model name if found, None otherwise
"""
last_model_seen = None
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
return str(data.get("model"))
except json.JSONDecodeError:
continue
except Exception as e:
logger.debug(
"Error processing chunk for model extraction",
extra={"error": str(e), "error_type": type(e).__name__},
)
return last_model_seen
class ResponsesApiProcessor(ResponseProcessor):
"""Processor specifically for Responses API responses."""
@staticmethod
def extract_usage_with_reasoning_tokens(
content_str: str,
) -> tuple[dict | None, str | None]:
"""Extract usage data including reasoning tokens from Responses API content.
Args:
content_str: Streaming response content as string
Returns:
Tuple of (usage_data, model) with reasoning tokens preserved
"""
usage_data = None
model = None
lines = content_str.strip().split("\n")
for line in lines:
if line.startswith("data: "):
try:
data_json = json.loads(line[6:])
if "usage" in data_json:
usage_data = data_json["usage"]
model = data_json.get("model")
# Note: We now only track input_tokens and output_tokens
# reasoning_tokens are ignored per the requirement
break
elif "model" in data_json and not model:
model = data_json["model"]
except json.JSONDecodeError:
continue
return usage_data, model
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from ..models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
-5
View File
@@ -1,5 +0,0 @@
# Example crontab entries for Routstr tasks
# Update models.json daily at 03:15 (optional)
# OUTPUT_FILE=/app/models.json SOURCE=openrouter
15 3 * * * /usr/local/bin/python /app/scripts/models_meta.py >> /var/log/cron.log 2>&1
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env python3
import json
import os
from typing import TypedDict
from urllib.request import urlopen
class ModelArchitecture(TypedDict):
modality: str
input_modalities: list[str]
output_modalities: list[str]
tokenizer: str
instruct_type: str | None
class ModelPricing(TypedDict):
prompt: str
completion: str
request: str
image: str
web_search: str
internal_reasoning: str
class ModelProvider(TypedDict):
context_length: int
max_completion_tokens: int | None
is_moderated: bool
class Model(TypedDict):
id: str
name: str
created: int
description: str
context_length: int
architecture: ModelArchitecture
pricing: ModelPricing
top_provider: ModelProvider
per_request_limits: dict | None
OUTPUT_FILE = os.getenv("OUTPUT_FILE", "models.json")
SOURCE = os.getenv("SOURCE")
def fetch_openrouter_models(source_filter: str | None = None) -> list[Model]:
"""Fetches model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
with urlopen(f"{base_url}/models") as response:
data = json.loads(response.read().decode("utf-8"))
models_data: list[Model] = []
for model in data.get("data", []):
model_id = model.get("id", "")
if source_filter:
source_prefix = f"{source_filter}/"
if not model_id.startswith(source_prefix):
continue
model = dict(model)
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if (
"(free)" in model.get("name", "")
or model_id == "openrouter/auto"
or model_id == "google/gemini-2.5-pro-exp-03-25"
):
continue
models_data.append(model)
return models_data
def main() -> None:
source_filter = SOURCE if SOURCE and SOURCE.strip() else None
models = fetch_openrouter_models(source_filter=source_filter)
print(f"Writing {len(models)} models to {OUTPUT_FILE}")
with open(OUTPUT_FILE, "w") as f:
json.dump({"models": models}, f, indent=4)
if __name__ == "__main__":
main()
+18 -21
View File
@@ -66,7 +66,7 @@ os.environ.update(test_env)
os.environ.pop("ADMIN_PASSWORD", None)
from routstr.core.db import ApiKey, get_session # noqa: E402
from routstr.core.db import TemporaryCredit, get_session # noqa: E402
from routstr.core.main import app, lifespan # noqa: E402
@@ -113,7 +113,7 @@ class TestmintWallet:
self.refund_history: List[Dict[str, Any]] = []
async def init(self) -> None:
"""Initialize the sixty_nuts wallet"""
"""Initialize the cashu wallet"""
# In mock mode, we don't actually create a real wallet
# This is just a placeholder for the mock implementation
self.wallet = None
@@ -280,7 +280,7 @@ class TestmintWallet:
return 100000 # 100k sats
async def credit_balance(
self, cashu_token: str, key: ApiKey, session: AsyncSession
self, cashu_token: str, key: TemporaryCredit, session: AsyncSession
) -> int:
"""Credit balance to API key - test implementation"""
try:
@@ -301,9 +301,9 @@ class TestmintWallet:
# Use atomic update to avoid lost update problem in concurrent scenarios
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(balance=ApiKey.balance + amount_msat)
update(TemporaryCredit)
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
.values(balance=TemporaryCredit.balance + amount_msat)
)
await session.execute(stmt)
await session.commit()
@@ -390,7 +390,7 @@ class DatabaseSnapshot:
async def capture(self) -> Dict[str, List[Dict]]:
"""Capture current database state"""
# Get all API keys with their data
result = await self.session.execute(select(ApiKey))
result = await self.session.execute(select(TemporaryCredit))
api_keys = result.scalars().all()
snapshot = {
@@ -398,10 +398,8 @@ class DatabaseSnapshot:
{
"hashed_key": key.hashed_key,
"balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
"refund_expiration_time": key.refund_expiration_time,
}
for key in api_keys
]
@@ -447,10 +445,8 @@ class DatabaseSnapshot:
for field in [
"balance",
"total_spent",
"total_requests",
"refund_address",
"key_expiry_time",
"refund_expiration_time",
]:
if old[field] != new[field]:
changes[field] = {
@@ -522,18 +518,19 @@ async def integration_app(
with (
patch("routstr.core.db.engine", integration_engine),
patch.object(_settings, "cashu_mints", [mint_url]),
patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance),
patch("routstr.wallet.send_token", testmint_wallet.send_token),
patch("routstr.wallet.send_to_lnurl", testmint_wallet.send_to_lnurl),
patch("routstr.wallet.recieve_token", testmint_wallet.redeem_token),
patch("routstr.wallet.get_balance", testmint_wallet.get_balance),
patch(
"routstr.payment.wallet.credit_balance", testmint_wallet.credit_balance
),
patch("routstr.payment.wallet.send_token", testmint_wallet.send_token),
patch("routstr.payment.wallet.recieve_token", testmint_wallet.redeem_token),
patch("routstr.payment.wallet.get_balance", testmint_wallet.get_balance),
patch("routstr.balance.send_token", testmint_wallet.send_token),
patch("routstr.balance.send_to_lnurl", testmint_wallet.send_to_lnurl),
patch("websockets.connect") as mock_websockets,
patch("routstr.payment.price.btc_usd_price", return_value=50000.0),
patch("routstr.payment.price.sats_usd_price", return_value=0.0005),
patch(
"routstr.payment.helpers.calculate_discounted_max_cost",
"routstr.payment.cost.calculate_discounted_max_cost",
side_effect=_passthrough_discount,
),
):
@@ -705,8 +702,8 @@ async def background_tasks_controller() -> AsyncGenerator[Any, None]:
original_periodic_payout: Optional[Callable] = None
try:
from routstr.payment.models import update_sats_pricing
from routstr.wallet import periodic_payout
from routstr.models.models import update_sats_pricing
from routstr.payment.wallet import periodic_payout
async def controlled_update_pricing() -> None:
while not controller.cancelled:
+9 -4
View File
@@ -1,18 +1,18 @@
"""
Real Cashu mint integration for integration tests.
This module provides a real sixty_nuts Wallet implementation that can be used
This module provides a real cashu Wallet implementation that can be used
with an actual Cashu mint instance for more thorough integration testing.
"""
import os
from typing import Optional, Tuple
from sixty_nuts import Wallet
from cashu.wallet.wallet import Wallet
class RealMintWallet:
"""Real Cashu mint wallet using sixty_nuts library"""
"""Real Cashu mint wallet using cashu library"""
def __init__(self, mint_url: str, nsec: str):
self.mint_url = mint_url
@@ -22,7 +22,12 @@ class RealMintWallet:
async def init(self) -> None:
"""Initialize the wallet connection"""
if not self._wallet:
self._wallet = await Wallet.create(nsec=self.nsec)
self._wallet = await Wallet.with_db(
self.mint_url,
db="sqlite+aiosqlite:///:memory:",
load_all_keysets=True,
unit="sat",
)
@property
def wallet(self) -> Wallet:
+45 -40
View File
@@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from routstr.core.db import ApiKey
from routstr.payment.models import Model, Pricing, update_sats_pricing
from routstr.wallet import periodic_payout
from routstr.core.db import TemporaryCredit
from routstr.models.models import Model, Pricing, update_sats_pricing
from routstr.payment.wallet import periodic_payout
@pytest.mark.asyncio
@@ -201,12 +201,12 @@ class TestRefundCheckTask:
) -> None:
"""Test that expired keys with balance and refund address are refunded"""
# Create an expired API key with balance
expired_key = ApiKey(
expired_key = TemporaryCredit(
hashed_key="expired_test_key",
balance=5000, # 5 sats in msats
refund_address="lnurl1test",
key_expiry_time=int(time.time()) - 3600, # Expired 1 hour ago
created_at=datetime.utcnow() - timedelta(days=1),
refund_expiration_time=int(time.time()) - 3600, # Expired 1 hour ago
created=datetime.utcnow() - timedelta(days=1),
)
integration_session.add(expired_key)
await integration_session.commit()
@@ -214,7 +214,7 @@ class TestRefundCheckTask:
# Mock the wallet send_to_lnurl method and get_session
with (
patch(
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=5)
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=5)
) as mock_send_to_lnurl,
patch("routstr.core.db.get_session") as mock_get_session,
):
@@ -233,8 +233,8 @@ class TestRefundCheckTask:
if (
expired_key.balance > 0
and expired_key.refund_address
and expired_key.key_expiry_time
and expired_key.key_expiry_time < current_time
and expired_key.refund_expiration_time
and expired_key.refund_expiration_time < current_time
):
# Call wallet send_to_lnurl to trigger the refund
amount_sats = expired_key.balance // 1000
@@ -258,12 +258,12 @@ class TestRefundCheckTask:
"""Test that refund check continues after mint errors"""
# Create multiple expired keys
for i in range(3):
key = ApiKey(
key = TemporaryCredit(
hashed_key=f"expired_key_{i}",
balance=1000 * (i + 1),
refund_address=f"lnurl{i}",
key_expiry_time=int(time.time()) - 3600,
created_at=datetime.utcnow(),
refund_expiration_time=int(time.time()) - 3600,
created=datetime.utcnow(),
)
integration_session.add(key)
await integration_session.commit()
@@ -279,7 +279,7 @@ class TestRefundCheckTask:
with (
patch(
"routstr.wallet.send_to_lnurl", mock_send_to_lnurl
"routstr.payment.lnurl.send_to_lnurl", mock_send_to_lnurl
) as mock_send_to_lnurl_patch,
patch("routstr.core.db.get_session") as mock_get_session,
):
@@ -293,15 +293,15 @@ class TestRefundCheckTask:
current_time = int(time.time())
from sqlalchemy import select as sa_select
result = await integration_session.execute(sa_select(ApiKey))
result = await integration_session.execute(sa_select(TemporaryCredit))
keys = result.scalars().all()
for key in keys:
if (
key.balance > 0
and key.refund_address
and key.key_expiry_time
and key.key_expiry_time < current_time
and key.refund_expiration_time
and key.refund_expiration_time < current_time
):
amount_sats = key.balance // 1000
try:
@@ -352,21 +352,21 @@ class TestRefundCheckTask:
current_time = int(time.time())
for data in keys_data:
key = ApiKey(
key = TemporaryCredit(
hashed_key=data["hashed_key"],
balance=data["balance"],
refund_address=data["refund_address"],
key_expiry_time=current_time - 3600
refund_expiration_time=current_time - 3600
if data["expired"]
else current_time + 3600,
created_at=datetime.utcnow(),
created=datetime.utcnow(),
)
integration_session.add(key)
await integration_session.commit()
with (
patch(
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=1)
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=1)
) as mock_send_to_lnurl,
patch("routstr.core.db.get_session") as mock_get_session,
):
@@ -382,15 +382,15 @@ class TestRefundCheckTask:
current_time = int(time.time())
from sqlalchemy import select as sa_select
result = await integration_session.execute(sa_select(ApiKey))
result = await integration_session.execute(sa_select(TemporaryCredit))
keys = result.scalars().all()
for key in keys:
if (
key.balance > 0
and key.refund_address
and key.key_expiry_time
and key.key_expiry_time < current_time
and key.refund_expiration_time
and key.refund_expiration_time < current_time
):
amount_sats = key.balance // 1000
await mock_send_to_lnurl(key.refund_address, amount=amount_sats)
@@ -410,7 +410,7 @@ class TestRefundCheckTask:
# Check final state
from sqlalchemy import select as sa_select
result = await integration_session.execute(sa_select(ApiKey))
result = await integration_session.execute(sa_select(TemporaryCredit))
remaining_keys_list = result.scalars().all()
remaining_ids = [k.hashed_key for k in remaining_keys_list]
@@ -454,10 +454,10 @@ class TestPeriodicPayoutTask:
for i in range(5):
balance = 10000 * (i + 1) # 10, 20, 30, 40, 50 sats
total_user_balance += balance
key = ApiKey(
key = TemporaryCredit(
hashed_key=f"user_key_{i}",
balance=balance,
created_at=datetime.utcnow(),
created=datetime.utcnow(),
)
integration_session.add(key)
await integration_session.commit()
@@ -466,9 +466,12 @@ class TestPeriodicPayoutTask:
wallet_balance = 200000 # 200 sats total
with (
patch("routstr.wallet.get_balance", AsyncMock(return_value=wallet_balance)),
patch(
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=None)
"routstr.payment.wallet.get_balance",
AsyncMock(return_value=wallet_balance),
),
patch(
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=None)
) as mock_send_to_lnurl,
):
# Mock environment variables
@@ -481,7 +484,7 @@ class TestPeriodicPayoutTask:
},
):
# Call periodic_payout directly (pay_out was renamed/refactored)
from routstr.wallet import periodic_payout
from routstr.payment.wallet import periodic_payout
await periodic_payout()
@@ -501,7 +504,7 @@ class TestPeriodicPayoutTask:
# key = ApiKey(
# hashed_key="single_user",
# balance=50000, # 50 sats
# created_at=datetime.utcnow(),
# created=datetime.utcnow(),
# )
# integration_session.add(key)
# await integration_session.commit()
@@ -538,7 +541,7 @@ class TestPeriodicPayoutTask:
# key = ApiKey(
# hashed_key="low_revenue_user",
# balance=95000, # 95 sats
# created_at=datetime.utcnow(),
# created=datetime.utcnow(),
# )
# integration_session.add(key)
# await integration_session.commit()
@@ -648,14 +651,14 @@ class TestTaskInteractions:
"""Test that database operations don't deadlock during concurrent task execution"""
# Create test data
for i in range(10):
key = ApiKey(
key = TemporaryCredit(
hashed_key=f"concurrent_key_{i}",
balance=1000 * i,
refund_address=f"lnurl{i}" if i % 2 == 0 else None,
key_expiry_time=int(time.time()) - 3600
refund_expiration_time=int(time.time()) - 3600
if i % 3 == 0
else int(time.time()) + 3600,
created_at=datetime.utcnow(),
created=datetime.utcnow(),
)
integration_session.add(key)
await integration_session.commit()
@@ -664,14 +667,14 @@ class TestTaskInteractions:
async def read_operation() -> int:
from sqlalchemy import select as sa_select
result = await integration_session.execute(sa_select(ApiKey))
result = await integration_session.execute(sa_select(TemporaryCredit))
return len(result.scalars().all())
async def write_operation(key_id: int) -> None:
from sqlalchemy import select as sa_select
stmt = sa_select(ApiKey).where(
ApiKey.hashed_key == f"concurrent_key_{key_id}" # type: ignore[arg-type]
stmt = sa_select(TemporaryCredit).where(
TemporaryCredit.hashed_key == f"concurrent_key_{key_id}" # type: ignore[arg-type]
)
result = await integration_session.execute(stmt)
key = result.scalar_one_or_none()
@@ -708,14 +711,16 @@ class TestTaskInteractions:
# Patch the actual task functions
with (
patch(
"routstr.payment.models.update_sats_pricing",
"routstr.models.models.update_sats_pricing",
lambda: task_with_cleanup("pricing"),
),
patch(
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("refund")
"routstr.payment.wallet.periodic_payout",
lambda: task_with_cleanup("refund"),
),
patch(
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("payout")
"routstr.payment.wallet.periodic_payout",
lambda: task_with_cleanup("payout"),
),
):
# Start all tasks
+27 -38
View File
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
class TestTransactionAtomicity:
@@ -34,7 +34,7 @@ class TestTransactionAtomicity:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
initial_balance = api_key.balance
@@ -47,7 +47,9 @@ class TestTransactionAtomicity:
try:
# Get api key in new session
result = await test_session.execute(
select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
select(TemporaryCredit).where(
TemporaryCredit.hashed_key == api_key_hash
) # type: ignore[arg-type]
)
test_api_key = result.scalar_one()
@@ -72,14 +74,14 @@ class TestTransactionAtomicity:
try:
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
.values(balance=ApiKey.balance - 1000)
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
.values(balance=TemporaryCredit.balance - 1000)
)
# Force a constraint violation or error
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == "non_existent_key") # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == "non_existent_key") # type: ignore[arg-type]
.values(balance=-1) # This should fail
)
await integration_session.commit()
@@ -108,13 +110,13 @@ class TestTransactionAtomicity:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
initial_balance = api_key.balance
# Mock wallet to fail after token validation
with patch("routstr.wallet.send_token") as mock_wallet_func:
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
mock_proof = MagicMock()
mock_proof.amount = 1000
mock_wallet = AsyncMock()
@@ -158,7 +160,7 @@ class TestTransactionAtomicity:
)
# Set a known balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
api_key.balance = 10000
@@ -166,12 +168,12 @@ class TestTransactionAtomicity:
# Simulate concurrent balance updates through direct database operations
async def update_balance(session: AsyncSession, amount: int) -> bool:
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(
TemporaryCredit.hashed_key == api_key_hash
) # type: ignore[arg-type]
result = await session.execute(stmt)
key = result.scalar_one()
key.balance -= amount
key.total_spent += amount
key.total_requests += 1
try:
await session.commit()
return True
@@ -248,7 +250,7 @@ class TestConcurrentOperations:
)
# Set initial balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
initial_balance = 5000
@@ -256,7 +258,7 @@ class TestConcurrentOperations:
await integration_session.commit()
# Mock wallet for topup
with patch("routstr.wallet.send_token") as mock_wallet_func:
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
mock_proof = MagicMock()
mock_proof.amount = 2000
mock_wallet = AsyncMock()
@@ -323,12 +325,10 @@ class TestConcurrentOperations:
)
# Set a specific balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
api_key.balance = 1000
api_key.total_spent = 0
api_key.total_requests = 0
await integration_session.commit()
# Create a controlled race condition scenario
@@ -336,7 +336,9 @@ class TestConcurrentOperations:
async def check_and_update_balance() -> bool:
# Read current balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(
TemporaryCredit.hashed_key == api_key_hash
) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
current_api_key = result.scalar_one()
current_balance = current_api_key.balance
@@ -347,8 +349,6 @@ class TestConcurrentOperations:
# Try to update based on read value
current_api_key.balance = current_balance - 100
current_api_key.total_spent += 100
current_api_key.total_requests += 1
try:
await integration_session.commit()
@@ -371,8 +371,6 @@ class TestConcurrentOperations:
# Final balance should reflect successful updates
expected_balance = 1000 - (successful_updates * 100)
assert api_key.balance == expected_balance
assert api_key.total_spent == successful_updates * 100
assert api_key.total_requests == successful_updates
class TestDataIntegrity:
@@ -396,7 +394,7 @@ class TestDataIntegrity:
)
# Set low balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
api_key.balance = 0
@@ -431,9 +429,7 @@ class TestDataIntegrity:
)
# Try to manually insert duplicate key with same hash
duplicate_key = ApiKey(
hashed_key=api_key_hash, balance=5000, total_spent=0, total_requests=0
)
duplicate_key = TemporaryCredit(hashed_key=api_key_hash, balance=5000)
integration_session.add(duplicate_key)
@@ -481,20 +477,13 @@ class TestDataIntegrity:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
# Test setting invalid values directly
# These should maintain integrity
assert api_key.balance >= 0
assert api_key.total_spent >= 0
assert api_key.total_requests >= 0
# Verify calculations are consistent
if api_key.total_requests > 0:
average_cost = api_key.total_spent / api_key.total_requests
assert average_cost >= 0
class TestPerformance:
@@ -522,7 +511,7 @@ class TestPerformance:
operation_times["select"].append((end - start) * 1000) # Convert to ms
# Test UPDATE performance (via topup)
with patch("routstr.wallet.send_token") as mock_wallet_func:
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
mock_proof = MagicMock()
mock_proof.amount = 100
mock_wallet = AsyncMock()
@@ -607,7 +596,7 @@ class TestPerformance:
# Primary key lookup should be fast
start = time.time()
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
end = time.time()
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient, ConnectError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
class TestNetworkFailureScenarios:
@@ -27,7 +27,7 @@ class TestNetworkFailureScenarios:
# Patch the wallet send function to simulate failure across all modules
with (
patch(
"routstr.wallet.send_token",
"routstr.payment.wallet.send_token",
AsyncMock(side_effect=ConnectError("Mint service unavailable")),
),
patch(
@@ -422,7 +422,7 @@ class TestRecoveryScenarios:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
initial_key = result.scalar_one()
initial_balance = initial_key.balance
@@ -458,17 +458,15 @@ class TestRecoveryScenarios:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
initial_balance = api_key.balance
initial_requests = api_key.total_requests
# Simulate operations that might be interrupted
try:
# Start a transaction
api_key.reserved_balance += 1000
api_key.total_requests += 1
# Don't commit - simulate crash
raise Exception("Simulated database crash")
except Exception:
@@ -478,7 +476,6 @@ class TestRecoveryScenarios:
# Verify state is consistent after "recovery"
await integration_session.refresh(api_key)
assert api_key.balance == initial_balance
assert api_key.total_requests == initial_requests
@pytest.mark.asyncio
async def test_state_consistency_after_failures(
@@ -527,10 +524,7 @@ class TestRecoveryScenarios:
for mod in diff["api_keys"]["modified"]:
# Only acceptable changes are request counts
for field, change in mod["changes"].items():
if field == "total_requests":
# Request count might increase
assert change["delta"] >= 0
elif field == "balance":
if field == "balance":
# Balance should not decrease from failed operations
assert change["delta"] >= 0
else:
@@ -641,12 +635,10 @@ class TestEdgeCaseCombinations:
api_key_hash = test_key[3:] # Remove sk- prefix
# Create the API key with only 500 msats (less than one request cost)
new_key = ApiKey(
new_key = TemporaryCredit(
hashed_key=api_key_hash,
balance=500, # Less than fixed cost per request (1000 msats)
reserved_balance=0,
total_spent=0,
total_requests=0,
)
integration_session.add(new_key)
await integration_session.commit()
@@ -687,7 +679,7 @@ class TestEdgeCaseCombinations:
assert insufficient_funds_count > 0
# Balance should never go negative
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
final_key = result.scalar_one()
assert final_key.balance >= 0
@@ -142,6 +142,44 @@ class TestPerformanceBaseline:
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
@pytest.mark.asyncio
async def test_database_query_performance(
self, integration_session: Any, db_snapshot: Any
) -> None:
"""Test database operation performance"""
from sqlmodel import select
from routstr.core.db import TemporaryCredit
# Create test data
for i in range(100):
key = TemporaryCredit(
hashed_key=f"test_key_{i}",
balance=1000000,
)
integration_session.add(key)
await integration_session.commit()
# Test query performance
query_times = []
for _ in range(100):
start = time.time()
result = await integration_session.execute(
select(TemporaryCredit).where(TemporaryCredit.balance > 0) # type: ignore[arg-type]
)
_ = result.all()
duration = (time.time() - start) * 1000
query_times.append(duration)
# All queries should complete < 100ms
assert max(query_times) < 100, (
f"Max query time {max(query_times)}ms exceeds 100ms limit"
)
print("\nDatabase query performance:")
print(f" Mean: {statistics.mean(query_times):.2f}ms")
print(f" Max: {max(query_times):.2f}ms")
@pytest.mark.integration
@pytest.mark.slow
+25 -25
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 PerformanceValidator, ResponseValidator
@@ -62,9 +62,9 @@ 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"}}
@@ -126,9 +126,9 @@ 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,
@@ -200,9 +200,9 @@ 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")
@@ -247,7 +247,7 @@ 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/")
@@ -308,10 +308,10 @@ 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")
@@ -377,9 +377,9 @@ 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",
@@ -416,7 +416,7 @@ 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/")
@@ -454,9 +454,9 @@ 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/")
@@ -486,9 +486,9 @@ 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
@@ -536,9 +536,9 @@ async def test_providers_endpoint_performance(integration_client: AsyncClient) -
validator = PerformanceValidator()
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 multiple requests
@@ -576,9 +576,9 @@ 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
@@ -618,9 +618,9 @@ 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
@@ -670,9 +670,9 @@ 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
@@ -14,7 +14,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
from .utils import (
ConcurrencyTester,
@@ -297,8 +297,8 @@ async def test_proxy_get_insufficient_balance(
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(balance=100) # Only 0.1 sats
)
await integration_session.commit()
@@ -382,7 +382,7 @@ async def test_proxy_get_database_state_verification(
# Get initial key state
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
initial_key = result.scalar_one()
initial_balance = initial_key.balance
+3 -3
View File
@@ -9,7 +9,7 @@ import os
try:
from .real_testmint import create_real_mint_wallet
except ImportError:
# sixty_nuts not available, tests will be skipped
# cashu not available, tests will be skipped
create_real_mint_wallet = None # type: ignore
@@ -17,9 +17,9 @@ async def test_real_wallet() -> None:
"""Test basic operations with a real Cashu mint wallet"""
print("Testing real Cashu mint wallet...")
# Check if sixty_nuts dependency is available
# Check if cashu dependency is available
if create_real_mint_wallet is None:
print("sixty_nuts not available. Skipping real mint tests.")
print("cashu not available. Skipping real mint tests.")
return
# Check if real mint is enabled
@@ -7,7 +7,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ApiKey, create_session
from routstr.core.db import TemporaryCredit, create_session
@pytest.mark.asyncio
@@ -16,7 +16,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
# Create a test API key with limited balance
async with create_session() as session:
test_key = ApiKey(
test_key = TemporaryCredit(
hashed_key="test_reserved_balance_key",
balance=1000, # 1 sat
reserved_balance=0,
@@ -40,7 +40,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
# Check reserved balance after failed request
async with create_session() as session:
key = await session.get(ApiKey, "test_reserved_balance_key")
key = await session.get(TemporaryCredit, "test_reserved_balance_key")
assert key is not None
assert key.reserved_balance >= 0, (
f"Reserved balance went negative: {key.reserved_balance}"
@@ -69,7 +69,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
# Check final state
async with create_session() as session:
key = await session.get(ApiKey, "test_reserved_balance_key")
key = await session.get(TemporaryCredit, "test_reserved_balance_key")
assert key is not None
assert key.reserved_balance >= 0, (
f"Reserved balance went negative after concurrent requests: {key.reserved_balance}"
@@ -86,7 +86,7 @@ async def test_reserved_balance_with_successful_requests(
# Create a test API key with more balance
async with create_session() as session:
unique_key = f"test_successful_key_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
test_key = TemporaryCredit(
hashed_key=unique_key,
balance=100000, # 100 sats
reserved_balance=0,
@@ -111,24 +111,23 @@ async def test_reserved_balance_with_successful_requests(
# Check that reserved balance was properly adjusted
async with create_session() as session:
key = await session.get(ApiKey, unique_key)
key = await session.get(TemporaryCredit, unique_key)
assert key is not None
assert key.reserved_balance >= 0, (
f"Reserved balance went negative: {key.reserved_balance}"
)
# Check if the request was processed (might fail due to model pricing in test env)
# The important part is that reserved_balance doesn't go negative
if key.total_spent > 0:
assert key.balance < 100000, (
"Balance should decrease after successful request"
)
if key.balance < 100000:
# Balance decreased
pass
else:
# Request failed, but reserved balance should still be non-negative
assert key.balance == 100000, (
"Balance should remain unchanged if request failed"
)
print(
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}, Spent: {key.total_spent}"
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}"
)
@@ -137,11 +136,11 @@ async def test_insufficient_reserved_balance_for_revert(
integration_session: AsyncSession,
) -> None:
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
from routstr.auth import revert_pay_for_request
from routstr.payment.helpers import revert_pay_for_request
# Create key with zero reserved balance
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
test_key = TemporaryCredit(
hashed_key=unique_key,
balance=1000,
reserved_balance=0,
@@ -160,6 +159,3 @@ async def test_insufficient_reserved_balance_for_revert(
assert test_key.reserved_balance == -100, (
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == -1, (
f"Expected total_requests to be -1, got: {test_key.total_requests}"
)
+12 -16
View File
@@ -11,7 +11,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
from .utils import (
CashuTokenGenerator,
@@ -55,13 +55,11 @@ async def test_api_key_generation_valid_token(
# Verify database state directly
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
assert db_key.balance == amount * 1000
assert db_key.total_spent == 0
assert db_key.total_requests == 0
# Verify the API key can be used for authentication
integration_client.headers["Authorization"] = f"Bearer {api_key}"
@@ -93,15 +91,15 @@ async def test_api_key_generation_invalid_token(
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
response = await integration_client.get("/v1/wallet/info")
# Should fail with 401
assert response.status_code == 401, (
f"Token {invalid_token[:20]}... should be invalid"
# Should fail with 401 or 400
assert response.status_code in [400, 401], (
f"Token {invalid_token[:20]}... should be invalid (400 or 401)"
)
# Validate error response
validator = ResponseValidator()
error_validation = validator.validate_error_response(
response, expected_status=401, expected_error_key="detail"
response, expected_status=response.status_code, expected_error_key="detail"
)
assert error_validation["valid"]
@@ -169,7 +167,7 @@ async def test_authorization_header_validation(
valid_api_key = response.json()["api_key"]
# Test scenarios
test_cases = [
test_cases: list[tuple[dict[str, str], int, str]] = [
# (headers, expected_status, description)
(
{},
@@ -193,7 +191,7 @@ async def test_authorization_header_validation(
integration_client.headers.pop("authorization", None)
# Set test headers
integration_client.headers.update(headers)
integration_client.headers.update(headers) # type: ignore[arg-type]
# Make request to protected endpoint
response = await integration_client.get("/v1/wallet/")
@@ -256,16 +254,14 @@ async def test_database_state_api_key_creation(
# Verify database record
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
# Validate stored data
assert db_key.balance == amount * 1000 # msats
assert db_key.total_spent == 0
assert db_key.total_requests == 0
assert db_key.refund_address is None
assert db_key.key_expiry_time is None
assert db_key.refund_expiration_time is None
# Creation timestamp should be recent (within last minute)
# Note: The model doesn't have a creation timestamp field,
@@ -446,7 +442,7 @@ async def test_concurrent_token_submissions(
for api_key in api_keys:
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
assert db_key.balance == expected_balances[hashed_key]
@@ -568,7 +564,7 @@ async def test_database_timestamp_accuracy(
# Verify key exists in database
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
+15 -13
View File
@@ -11,7 +11,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select, update
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
from .utils import ConcurrencyTester, ResponseValidator
@@ -49,7 +49,7 @@ async def test_wallet_endpoint_with_valid_api_key(
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
@@ -138,7 +138,9 @@ async def test_wallet_with_zero_balance(
# Manually set balance to zero in database
hashed_key = api_key[3:] # Remove "sk-" prefix
await integration_session.execute(
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key)
.values(balance=0) # type: ignore[arg-type]
)
await integration_session.commit()
@@ -181,9 +183,11 @@ async def test_expired_api_key_behavior(
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
.values(key_expiry_time=past_expiry, refund_address="test@lightning.address")
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(
refund_expiration_time=past_expiry, refund_address="test@lightning.address"
)
)
await integration_session.commit()
@@ -197,10 +201,10 @@ async def test_expired_api_key_behavior(
# Verify expiry time was stored
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
assert db_key.key_expiry_time == past_expiry
assert db_key.refund_expiration_time == past_expiry
assert db_key.refund_address == "test@lightning.address"
@@ -270,7 +274,7 @@ async def test_wallet_info_data_consistency(
# Verify against database
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
@@ -360,12 +364,10 @@ async def test_wallet_after_partial_spending(
hashed_key = api_key[3:] # Remove "sk-" prefix
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(
balance=initial_balance - spent_amount,
total_spent=spent_amount,
total_requests=5, # Simulate 5 requests
)
)
await integration_session.commit()
+16 -14
View File
@@ -13,7 +13,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
@pytest.mark.integration
@@ -123,7 +123,9 @@ async def test_zero_balance_refund_handling(
from sqlmodel import update
await integration_session.execute(
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key)
.values(balance=0) # type: ignore[arg-type]
)
await integration_session.commit()
@@ -136,7 +138,7 @@ async def test_zero_balance_refund_handling(
# Key should still exist
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
assert result.scalar_one_or_none() is not None
@@ -159,7 +161,7 @@ async def test_refund_amount_validation(
# Verify the key has no refund address (needed for the "too small" check)
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
key = result.scalar_one()
assert key.refund_address is None
@@ -192,8 +194,8 @@ async def test_refund_with_lightning_address(
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(refund_address=refund_address)
)
await integration_session.commit()
@@ -252,7 +254,7 @@ async def test_database_state_after_refund(
# Verify key exists before refund
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
key_before = result.scalar_one()
assert key_before.balance == 10_000_000
@@ -263,12 +265,12 @@ async def test_database_state_after_refund(
# Verify key is deleted after refund
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
assert result.scalar_one_or_none() is None
# Count total keys to ensure only the specific one was deleted
result = await integration_session.execute(select(ApiKey))
result = await integration_session.execute(select(TemporaryCredit))
remaining_keys = result.scalars().all()
# Should have no keys left (assuming clean test environment)
assert len(remaining_keys) == 0
@@ -480,8 +482,8 @@ async def test_refund_error_handling(
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(balance=-1000) # Invalid negative balance
)
await integration_session.commit()
@@ -517,9 +519,9 @@ async def test_refund_with_expired_key(
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
.values(key_expiry_time=past_expiry, refund_address="expired@ln.address")
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(refund_expiration_time=past_expiry, refund_address="expired@ln.address")
)
await integration_session.commit()
+2 -2
View File
@@ -11,7 +11,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
from .utils import (
CashuTokenGenerator,
@@ -64,7 +64,7 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
# Get the hashed key from the API key
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
+4 -20
View File
@@ -9,7 +9,7 @@ import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
class CashuTokenGenerator:
@@ -97,11 +97,11 @@ class DatabaseStateValidator:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_api_key(self, api_key: str) -> Optional[ApiKey]:
async def get_api_key(self, api_key: str) -> Optional[TemporaryCredit]:
"""Get API key from database"""
hashed_key = hashlib.sha256(api_key.encode()).hexdigest()
result = await self.session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
return result.scalar_one_or_none()
@@ -125,20 +125,6 @@ class DatabaseStateValidator:
"current_balance": key_obj.balance,
}
async def validate_request_count(
self, api_key: str, expected_count: int
) -> Dict[str, Any]:
"""Validate request count for an API key"""
key_obj = await self.get_api_key(api_key)
if not key_obj:
return {"valid": False, "error": "API key not found"}
return {
"valid": key_obj.total_requests == expected_count,
"expected": expected_count,
"actual": key_obj.total_requests,
}
async def validate_atomic_update(
self, api_key: str, field: str, expected_value: Any
) -> bool:
@@ -315,7 +301,7 @@ class ConcurrencyTester:
)
tasks = [make_request(req) for req in requests]
return await asyncio.gather(*tasks, return_exceptions=False)
return await asyncio.gather(*tasks, return_exceptions=False) # type: ignore
@staticmethod
async def test_race_condition(
@@ -463,8 +449,6 @@ class TestDataBuilder:
"""Create test API key data"""
data: Dict[str, Any] = {
"balance": balance,
"total_spent": 0,
"total_requests": 0,
}
if refund_address:
+2 -2
View File
@@ -44,9 +44,9 @@ def check_imports() -> bool:
print("Conftest fixtures imported successfully")
# Check routstr modules - imports are for verification only
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
del ApiKey
del TemporaryCredit
print("Router modules imported successfully")
+2 -2
View File
@@ -7,12 +7,12 @@ from unittest.mock import Mock
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.algorithm import ( # noqa: E402
from routstr.models.algorithm import ( # noqa: E402
calculate_model_cost_score,
get_provider_penalty,
should_prefer_model,
)
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
from routstr.models.models import Architecture, Model, Pricing # noqa: E402
def create_test_model(
+2 -2
View File
@@ -21,11 +21,11 @@ import pytest
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.payment.models import ( # noqa: E402
from routstr.models.crud import _model_to_row_payload
from routstr.models.models import ( # noqa: E402
Architecture,
Model,
Pricing,
_model_to_row_payload,
)
+8 -8
View File
@@ -4,10 +4,10 @@ from io import BytesIO
import pytest
from PIL import Image
from routstr.payment.helpers import (
from routstr.payment.cost import (
_calculate_image_tokens,
_estimate_image_tokens_in_messages,
_get_image_dimensions,
estimate_image_tokens_in_messages,
)
@@ -81,7 +81,7 @@ async def test_estimate_image_tokens_base64() -> None:
}
]
tokens = await estimate_image_tokens_in_messages(messages)
tokens = await _estimate_image_tokens_in_messages(messages)
assert tokens > 0
@@ -108,7 +108,7 @@ async def test_estimate_image_tokens_multiple_images() -> None:
}
]
tokens = await estimate_image_tokens_in_messages(messages)
tokens = await _estimate_image_tokens_in_messages(messages)
assert tokens > 0
@@ -148,8 +148,8 @@ async def test_estimate_image_tokens_with_detail() -> None:
}
]
tokens_low = await estimate_image_tokens_in_messages(messages_low)
tokens_high = await estimate_image_tokens_in_messages(messages_high)
tokens_low = await _estimate_image_tokens_in_messages(messages_low)
tokens_high = await _estimate_image_tokens_in_messages(messages_high)
assert tokens_low == 85
assert tokens_high > tokens_low
@@ -163,7 +163,7 @@ async def test_estimate_image_tokens_no_images() -> None:
{"role": "assistant", "content": "I'm doing well, thank you!"},
]
tokens = await estimate_image_tokens_in_messages(messages)
tokens = await _estimate_image_tokens_in_messages(messages)
assert tokens == 0
@@ -185,5 +185,5 @@ async def test_estimate_image_tokens_input_image_type() -> None:
}
]
tokens = await estimate_image_tokens_in_messages(messages)
tokens = await _estimate_image_tokens_in_messages(messages)
assert tokens > 0
-118
View File
@@ -1,118 +0,0 @@
"""Unit tests for the logging SecurityFilter.
This module tests that the SecurityFilter correctly identifies and redacts
sensitive information from log messages without causing false positives.
"""
import logging
from collections.abc import Callable
import pytest
from routstr.core.logging import SecurityFilter
@pytest.fixture
def security_filter() -> SecurityFilter:
"""Provide an instance of the SecurityFilter for testing."""
return SecurityFilter()
@pytest.fixture
def filter_message(security_filter: SecurityFilter) -> Callable[[str], str]:
"""A helper fixture to apply the filter to a message string."""
def _filter(msg: str) -> str:
record = logging.LogRecord(
name="test_logger",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
security_filter.filter(record)
return record.getMessage()
return _filter
def test_redacts_unquoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
"""Test that an unquoted key-value pair is correctly redacted."""
original = "Processing request with api_key=sk-12345abcdef"
expected = "Processing request with api_key: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_quoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
"""Test that a quoted token is correctly redacted."""
original = 'User authenticated with token="cashuA123abc"'
expected = "User authenticated with token: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_bearer_token(filter_message: Callable[[str], str]) -> None:
"""Test that a Bearer token of sufficient length is redacted."""
original = "Authorization: Bearer abc1234567890xyzabcdefg"
expected = "Authorization: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_cashu_token(filter_message: Callable[[str], str]) -> None:
"""Test that a Cashu token is redacted."""
original = "Received cashuTOKENeyJ0b2tlbiI6W3siaWQiOiI"
expected = "Received [REDACTED]"
assert filter_message(original) == expected
def test_redacts_nsec_key(filter_message: Callable[[str], str]) -> None:
"""Test that a full-length Nostr private key is redacted."""
original = "Private key is nsec1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f"
expected = "Private key is [REDACTED]"
assert filter_message(original) == expected
def test_ignores_non_sensitive_message(filter_message: Callable[[str], str]) -> None:
"""Test that a message with no sensitive data is left untouched."""
original = "No token pricing configured, using base cost"
expected = "No token pricing configured, using base cost"
assert filter_message(original) == expected
def test_multiple_secrets_in_one_message(filter_message: Callable[[str], str]) -> None:
"""Test that multiple different secrets in one message are all redacted."""
original = 'Auth with Bearer abcdefghijklmnopqrstuvwxyz and api_key="sk-12345"'
expected = "Auth with [REDACTED] and api_key: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_key_with_no_value(filter_message: Callable[[str], str]) -> None:
"""Test that a key with no value is not redacted."""
original = "Request contains api_key and secret."
expected = "Request contains api_key and secret."
assert filter_message(original) == expected
def test_redacts_key_value_with_spaces(filter_message: Callable[[str], str]) -> None:
"""Test that key-value pairs with extra spaces are correctly redacted."""
original = "Auth info: api_key = 'sk-12345'"
expected = "Auth info: api_key: [REDACTED]"
assert filter_message(original) == expected
def test_is_case_insensitive_for_keys(filter_message: Callable[[str], str]) -> None:
"""Test that key matching is case-insensitive."""
original = "TOKEN=sk-abcdef12345"
expected = "token: [REDACTED]"
assert filter_message(original) == expected
def test_is_case_insensitive_for_standalone(
filter_message: Callable[[str], str],
) -> None:
"""Test that standalone matching is case-insensitive."""
original = "Using NSEC1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f and CaShuA123abc"
expected = "Using [REDACTED] and [REDACTED]"
assert filter_message(original) == expected
+14 -32
View File
@@ -7,11 +7,11 @@ os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.core.settings import settings # noqa: E402
from routstr.payment.helpers import get_max_cost_for_model # noqa: E402
from routstr.payment.cost import get_max_cost_for_model # noqa: E402
async def test_get_max_cost_for_model_known() -> None:
from routstr.payment.models import Pricing
from routstr.models.models import Pricing
# Mock DB session behavior
mock_session = AsyncMock()
@@ -63,48 +63,32 @@ async def test_get_max_cost_for_model_known() -> None:
with patch.object(settings, "fixed_pricing", False):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model(
"gpt-4", session=mock_session, model_obj=mock_model
)
cost = get_max_cost_for_model(model_obj=mock_model)
assert cost == 500000 # 500 sats * 1000 = msats
async def test_get_max_cost_for_model_unknown() -> None:
mock_session = AsyncMock()
mock_model = Mock()
mock_model.sats_pricing = None
mock_model.id = "unknown-model"
# Mock the exec results to return no model override
async def async_mock_exec(query: Any) -> Any:
result = Mock()
result.first = Mock(return_value=None)
result.all = Mock(return_value=[])
return result
mock_session.exec = AsyncMock(side_effect=async_mock_exec)
mock_session.get = AsyncMock(return_value=None)
# Mock get_upstreams to return empty list
with patch("routstr.proxy.get_upstreams", return_value=[]):
with patch.object(settings, "fixed_cost_per_request", 100):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model(
"unknown-model", session=mock_session, model_obj=None
)
assert cost == 100000
with patch.object(settings, "fixed_cost_per_request", 100):
with patch.object(settings, "tolerance_percentage", 0):
cost = get_max_cost_for_model(model_obj=mock_model)
assert cost == 100000
async def test_get_max_cost_for_model_disabled() -> None:
mock_session = AsyncMock()
mock_model = Mock()
with patch.object(settings, "fixed_pricing", True):
with patch.object(settings, "fixed_cost_per_request", 200):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model("any-model", session=mock_session)
cost = get_max_cost_for_model(model_obj=mock_model)
assert cost == 200000
async def test_get_max_cost_for_model_tolerance() -> None:
from routstr.payment.models import Pricing
mock_session = AsyncMock()
from routstr.models.models import Pricing
# Mock the model with sats_pricing
mock_pricing = Pricing(
@@ -121,7 +105,5 @@ async def test_get_max_cost_for_model_tolerance() -> None:
with patch.object(settings, "fixed_pricing", False):
with patch.object(settings, "tolerance_percentage", 10):
cost = await get_max_cost_for_model(
"gpt-4", session=mock_session, model_obj=mock_model
)
cost = get_max_cost_for_model(model_obj=mock_model)
assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000
+23 -12
View File
@@ -4,8 +4,13 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from routstr.core.db import ApiKey
from routstr.wallet import credit_balance, get_balance, recieve_token, send_token
from routstr.core.db import TemporaryCredit
from routstr.payment.wallet import (
credit_balance,
get_balance,
recieve_token,
send_token,
)
@pytest.mark.asyncio
@@ -15,7 +20,7 @@ async def test_get_balance() -> None:
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
balance = await get_balance("sat")
assert balance == 50000
@@ -43,7 +48,9 @@ async def test_recieve_token_valid() -> None:
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
with patch(
"routstr.payment.wallet.deserialize_token_from_string"
) as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://mint:3338"
@@ -54,7 +61,9 @@ async def test_recieve_token_valid() -> None:
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch(
"routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet
):
amount, unit, mint = await recieve_token(token_str)
assert amount == 1000
assert unit == "sat"
@@ -65,8 +74,8 @@ async def test_recieve_token_valid() -> None:
async def test_send_token() -> None:
mock_wallet = Mock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.wallet.send", return_value=(1000, "test_token")):
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.payment.wallet.send", return_value=(1000, "test_token")):
token = await send_token(1000, "sat", "http://mint:3338")
assert token == "test_token"
@@ -87,7 +96,7 @@ async def test_credit_balance() -> None:
mock_session = AsyncMock()
# Mock session.refresh to update the balance (simulates DB reload)
async def mock_refresh(key: ApiKey) -> None:
async def mock_refresh(key: TemporaryCredit) -> None:
key.balance = 6000000
mock_session.refresh.side_effect = mock_refresh
@@ -96,7 +105,7 @@ async def test_credit_balance() -> None:
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
"routstr.payment.wallet.recieve_token",
return_value=(1000, "sat", "http://mint:3338"),
):
amount = await credit_balance(token_str, mock_key, mock_session)
@@ -112,7 +121,9 @@ async def test_credit_balance() -> None:
async def test_recieve_token_untrusted_mint() -> None:
mock_wallet = Mock()
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
with patch(
"routstr.payment.wallet.deserialize_token_from_string"
) as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://untrusted:3338"
@@ -122,9 +133,9 @@ async def test_recieve_token_untrusted_mint() -> None:
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
with patch(
"routstr.wallet.swap_to_primary_mint",
"routstr.payment.wallet.swap_to_primary_mint",
return_value=(900, "sat", "http://mint:3338"),
):
amount, unit, mint = await recieve_token("test_token")
+3 -1
View File
@@ -29,7 +29,9 @@ export default function BalancesPage() {
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
<h1 className='text-3xl font-bold tracking-tight'>
Balances
</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
+2 -2
View File
@@ -78,8 +78,8 @@ export default function AdminLoginPage(): ReactElement {
};
return (
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
<div className='flex min-h-screen items-center justify-center bg-background px-4 py-12 text-foreground'>
<Card className='w-full max-w-md border border-border/60 bg-card/90 shadow-2xl shadow-black/30 backdrop-blur'>
<CardHeader className='space-y-1'>
<CardTitle className='text-center text-2xl font-bold'>
Admin Login
+3 -10
View File
@@ -97,7 +97,7 @@ export function LogDetailsDialog({
<div>
<h4 className='mb-2 text-sm font-medium'>Message</h4>
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
<pre className='font-mono text-sm break-all whitespace-pre'>
<pre className='font-mono text-sm whitespace-pre break-all'>
{log.message}
</pre>
</div>
@@ -116,12 +116,7 @@ export function LogDetailsDialog({
<Button
variant='outline'
size='sm'
onClick={() =>
copyToClipboard(
String(log[field as keyof LogEntry] || ''),
field
)
}
onClick={() => copyToClipboard(String(log[field as keyof LogEntry] || ''), field)}
className='h-6 flex-shrink-0 px-2'
>
{copiedField === field ? (
@@ -180,9 +175,7 @@ export function LogDetailsDialog({
<Button
variant='outline'
size='sm'
onClick={() =>
copyToClipboard(JSON.stringify(log, null, 2), 'json')
}
onClick={() => copyToClipboard(JSON.stringify(log, null, 2), 'json')}
className='h-6 px-2'
>
{copiedField === 'json' ? (
+18 -30
View File
@@ -204,59 +204,47 @@ export default function ModelsPage() {
</div>
</div>
</div>
{totalModels === 0 && (
<Alert className='mb-4'>
{totalModels === 0 ? (
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='space-y-2'>
<p className='font-medium'>
No models found for this provider
</p>
<div className='space-y-1 text-sm'>
<p className='font-medium'>
Common issues:
</p>
<ul className='ml-2 list-inside list-disc space-y-1'>
<div className='text-sm space-y-1'>
<p className='font-medium'>Common issues:</p>
<ul className='list-disc list-inside space-y-1 ml-2'>
<li>
<strong>API credentials:</strong>{' '}
Check if the API key is correct
and has the right permissions
<strong>API credentials:</strong> Check if the API key is correct and has the right permissions
</li>
<li>
<strong>Base URL:</strong> Verify
the base URL is correct for your
provider
<strong>Base URL:</strong> Verify the base URL is correct for your provider
</li>
<li>
<strong>Network access:</strong>{' '}
Ensure the server can reach the
provider&apos;s API endpoint
<strong>Network access:</strong> Ensure the server can reach the provider&apos;s API endpoint
</li>
<li>
<strong>Provider status:</strong>{' '}
The upstream provider might be
temporarily unavailable
<strong>Provider status:</strong> The upstream provider might be temporarily unavailable
</li>
</ul>
{groupData?.group_url && (
<p className='text-muted-foreground mt-2 text-xs'>
Current endpoint:{' '}
<code className='bg-muted rounded px-1'>
{groupData.group_url}
</code>
<p className='mt-2 text-xs text-muted-foreground'>
Current endpoint: <code className='bg-muted px-1 rounded'>{groupData.group_url}</code>
</p>
)}
</div>
</div>
</AlertDescription>
</Alert>
) : (
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
)}
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
</div>
</TabsContent>
);
+23 -49
View File
@@ -53,7 +53,7 @@ export default function DashboardPage() {
window.removeEventListener('storage', syncAuthState);
};
}, []);
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
@@ -131,13 +131,8 @@ export default function DashboardPage() {
<SiteHeader />
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8'>
<h1 className='mb-6 text-3xl font-bold tracking-tight'>
Dashboard
</h1>
<DashboardBalanceSummary
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
<h1 className='text-3xl font-bold tracking-tight mb-6'>Dashboard</h1>
<DashboardBalanceSummary displayUnit={displayUnit} usdPerSat={usdPerSat} />
</div>
<div className='mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
@@ -145,9 +140,8 @@ export default function DashboardPage() {
<h2 className='text-xl font-semibold tracking-tight'>
Usage Analytics
</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Monitor requests, errors, and revenue over the last {timeRange}{' '}
hours
<p className='text-muted-foreground text-sm mt-1'>
Monitor requests, errors, and revenue over the last {timeRange} hours
</p>
</div>
<div className='flex items-center gap-4'>
@@ -182,31 +176,27 @@ export default function DashboardPage() {
<div className='space-y-6'>
{summaryLoading ? (
<div className='py-8 text-center'>Loading summary...</div>
<div className='text-center py-8'>Loading summary...</div>
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<div className='grid gap-6 lg:grid-cols-2'>
{metricsLoading ? (
<div className='col-span-2 py-8 text-center'>
<div className='text-center py-8 col-span-2'>
Loading metrics...
</div>
) : metricsData && metricsData.metrics.length > 0 ? (
<>
<div className='col-span-full'>
<div className="col-span-full">
<UsageMetricsChart
data={
metricsData.metrics.map((m) => ({
...m,
revenue_sats: m.revenue_msats / 1000,
refunds_sats: m.refunds_msats / 1000,
net_revenue_sats:
(m.revenue_msats - m.refunds_msats) / 1000,
})) as Array<
Record<string, unknown> & { timestamp: string }
>
}
data={metricsData.metrics.map((m) => ({
...m,
revenue_sats: m.revenue_msats / 1000,
refunds_sats: m.refunds_msats / 1000,
net_revenue_sats:
(m.revenue_msats - m.refunds_msats) / 1000,
})) as Array<Record<string, unknown> & { timestamp: string }>}
title='Revenue Over Time (sats)'
dataKeys={[
{
@@ -228,11 +218,7 @@ export default function DashboardPage() {
/>
</div>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Request Volume'
dataKeys={[
{
@@ -253,11 +239,7 @@ export default function DashboardPage() {
]}
/>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Error Tracking'
dataKeys={[
{
@@ -278,11 +260,7 @@ export default function DashboardPage() {
]}
/>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Payment Activity'
dataKeys={[
{
@@ -292,7 +270,7 @@ export default function DashboardPage() {
},
]}
/>
<div className='space-y-6'>
<div className="space-y-6">
{summaryData && summaryData.unique_models.length > 0 && (
<Card>
<CardHeader>
@@ -328,9 +306,7 @@ export default function DashboardPage() {
key={type}
className='flex items-center justify-between'
>
<span className='text-sm font-medium'>
{type}
</span>
<span className='text-sm font-medium'>{type}</span>
<span className='text-muted-foreground text-sm'>
{count}
</span>
@@ -359,18 +335,16 @@ export default function DashboardPage() {
</div>
{revenueByModelLoading ? (
<div className='py-8 text-center'>
Loading revenue by model...
</div>
<div className='text-center py-8'>Loading revenue by model...</div>
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
<RevenueByModelTable
<RevenueByModelTable
models={revenueByModelData.models}
totalRevenue={revenueByModelData.total_revenue_sats}
/>
) : null}
{errorLoading ? (
<div className='py-8 text-center'>Loading errors...</div>
<div className='text-center py-8'>Loading errors...</div>
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}
+35 -608
View File
@@ -18,9 +18,7 @@ import {
UpstreamProvider,
CreateUpstreamProvider,
UpdateUpstreamProvider,
AdminModel,
} from '@/lib/api/services/admin';
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
@@ -53,329 +51,9 @@ import {
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { toast } from 'sonner';
function ProviderBalance({
providerId,
platformUrl,
}: {
providerId: number;
platformUrl?: string | null;
}) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
const [topupError, setTopupError] = useState('');
const [isHovered, setIsHovered] = useState(false);
const [invoiceData, setInvoiceData] = useState<{
payment_request: string;
invoice_id: string;
} | null>(null);
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
null
);
const queryClient = useQueryClient();
const {
data: balanceData,
isLoading,
error,
} = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
refetchOnWindowFocus: true,
retry: 1,
});
const { data: statusData } = useQuery({
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
queryFn: () =>
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
enabled: !!invoiceData && paymentStatus === 'pending',
refetchInterval: 2000,
});
useEffect(() => {
if (statusData?.paid === true) {
setPaymentStatus('paid');
queryClient.invalidateQueries({
queryKey: ['provider-balance', providerId],
});
toast.success('Payment received!', {
description: 'Your balance has been updated.',
});
}
}, [statusData, queryClient, providerId]);
const topupMutation = useMutation({
mutationFn: async (amount: number) => {
console.log('Calling top-up API with:', { providerId, amount });
try {
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
console.log('API returned:', result);
return result;
} catch (err) {
console.error('API call failed:', err);
throw err;
}
},
onSuccess: (data) => {
console.log('Top-up response:', data);
console.log('Type of data:', typeof data);
console.log('Keys in data:', Object.keys(data || {}));
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
setInvoiceData({
payment_request: data.topup_data.payment_request as string,
invoice_id: data.topup_data.invoice_id as string,
});
setPaymentStatus('pending');
} else {
console.error('Missing invoice data:', data);
console.error('topup_data:', data?.topup_data);
toast.error('No invoice returned from provider');
setIsTopupDialogOpen(false);
}
},
onError: (error: Error) => {
console.error('Top-up mutation error:', error);
toast.error(`Failed to initiate top-up: ${error.message}`);
},
});
const handleTopup = () => {
// If no dialog open logic (which depends on API implementation),
// we check if we should redirect or open dialog based on available info
// But since this function is called inside the dialog, we might want to change
// how the "Top Up" button behaves instead.
const amount = parseFloat(topupAmount);
if (isNaN(amount)) {
setTopupError('Please enter a valid amount');
return;
}
if (amount < 1 || amount > 500) {
setTopupError('Amount must be between $1 and $500');
return;
}
topupMutation.mutate(amount);
};
const handleTopUpClick = () => {
// Check if the provider supports direct topup (currently only PPQ.AI effectively)
// We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability
// For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented),
// we should redirect.
// However, the prompt asks to redirect if topup is not implemented.
// The backend throws 500/400 if not implemented.
// A better approach is to check if we have a platform URL and maybe redirect there
// if we know it's not supported.
// BUT, we don't know for sure if it's supported without checking metadata or trying.
// Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance".
// Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect?
// Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect?
// The user specifically mentioned "like in openrouter".
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
const handleCloseDialog = () => {
setIsTopupDialogOpen(false);
setTopupAmount('');
setTopupError('');
setInvoiceData(null);
setPaymentStatus(null);
};
if (isLoading) {
return <Skeleton className='h-9 w-24' />;
}
if (error || !balanceData?.ok || !balanceData.balance_data) {
return null;
}
const balance = balanceData.balance_data;
let displayValue = 'N/A';
if (typeof balance === 'number') {
displayValue = `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
// Legacy support for object response
const b = balance as Record<string, unknown>;
if (typeof b.balance === 'number') {
displayValue = `$${b.balance.toFixed(2)}`;
} else if (typeof b.balance === 'string') {
displayValue = b.balance;
} else if (b.amount !== undefined) {
displayValue = `$${Number(b.amount).toFixed(2)}`;
}
}
return (
<>
<Button
variant='outline'
size='sm'
onClick={handleTopUpClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='w-full font-mono sm:w-auto'
>
{isHovered ? 'Top Up' : displayValue}
</Button>
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>
{paymentStatus === 'paid'
? 'Payment Confirmed!'
: 'Top Up Balance'}
</DialogTitle>
<DialogDescription>
{paymentStatus === 'paid'
? 'Your account balance has been updated.'
: invoiceData
? 'Scan the QR code or copy the Lightning invoice to pay.'
: 'Enter the amount you want to add to your account balance.'}
</DialogDescription>
</DialogHeader>
{paymentStatus === 'paid' ? (
<div className='flex flex-col items-center gap-4 py-6'>
<div className='rounded-full bg-green-100 p-3 dark:bg-green-900'>
<svg
className='h-12 w-12 text-green-600 dark:text-green-400'
fill='none'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path d='M5 13l4 4L19 7'></path>
</svg>
</div>
<p className='text-center font-semibold'>Top-up successful!</p>
</div>
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'>
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
alt='Lightning Invoice QR Code'
className='h-64 w-64'
/>
</div>
<div className='w-full space-y-2'>
<Label htmlFor='invoice'>Lightning Invoice</Label>
<div className='flex gap-2'>
<Input
id='invoice'
value={invoiceData.payment_request}
readOnly
className='font-mono text-xs'
/>
<Button
size='sm'
variant='outline'
onClick={() => {
navigator.clipboard.writeText(
invoiceData.payment_request
);
toast.success('Invoice copied to clipboard!');
}}
>
Copy
</Button>
</div>
</div>
{paymentStatus === 'pending' && (
<p className='text-muted-foreground text-center text-sm'>
Waiting for payment...
</p>
)}
</div>
) : (
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='topup_amount'>Amount (USD)</Label>
<Input
id='topup_amount'
type='number'
placeholder='Enter amount (1-500)'
value={topupAmount}
onChange={(e) => {
setTopupAmount(e.target.value);
setTopupError('');
}}
min='1'
max='500'
step='0.01'
/>
{topupError && (
<p className='text-sm text-red-600 dark:text-red-400'>
{topupError}
</p>
)}
</div>
</div>
)}
<DialogFooter>
{paymentStatus === 'paid' ? (
<Button onClick={handleCloseDialog} className='w-full'>
Done
</Button>
) : invoiceData ? (
<Button
variant='outline'
onClick={handleCloseDialog}
className='w-full'
>
Cancel
</Button>
) : (
<>
<Button variant='outline' onClick={handleCloseDialog}>
Cancel
</Button>
<Button
onClick={handleTopup}
disabled={topupMutation.isPending || !topupAmount}
>
{topupMutation.isPending
? 'Processing...'
: 'Generate Invoice'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default function ProvidersPage() {
const queryClient = useQueryClient();
const [editingProvider, setEditingProvider] =
@@ -386,18 +64,6 @@ export default function ProvidersPage() {
new Set()
);
const [viewingModels, setViewingModels] = useState<number | null>(null);
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
const [modelDialogState, setModelDialogState] = useState<{
isOpen: boolean;
providerId: number | null;
mode: 'create' | 'edit' | 'override';
initialData?: AdminModel | null;
}>({
isOpen: false,
providerId: null,
mode: 'create',
initialData: null,
});
const [formData, setFormData] = useState<CreateUpstreamProvider>({
provider_type: 'openrouter',
@@ -405,13 +71,8 @@ export default function ProvidersPage() {
api_key: '',
api_version: null,
enabled: true,
provider_fee: 1.06,
});
const getProviderFeePlaceholder = (type: string) => {
return type === 'openrouter' ? 'Default: 1.06 (6%)' : 'Default: 1.01 (1%)';
};
const { data: providerTypes = [] } = useQuery({
queryKey: ['provider-types'],
queryFn: () => AdminService.getProviderTypes(),
@@ -477,32 +138,6 @@ export default function ProvidersPage() {
},
});
const handleCreateAccount = async () => {
setIsCreatingAccount(true);
try {
const response = await AdminService.createProviderAccountByType(
formData.provider_type
);
if (response.ok && response.account_data.api_key) {
setFormData({
...formData,
api_key: String(response.account_data.api_key),
});
toast.success(
'Account created successfully! API key has been filled in.'
);
} else {
toast.success('Account created, but no API key returned.');
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
toast.error(`Failed to create account: ${errorMessage}`);
} finally {
setIsCreatingAccount(false);
}
};
const resetForm = () => {
setFormData({
provider_type: 'openrouter',
@@ -514,11 +149,7 @@ export default function ProvidersPage() {
};
const handleCreate = () => {
const data = { ...formData };
if (data.provider_fee === undefined || data.provider_fee === null) {
data.provider_fee = data.provider_type === 'openrouter' ? 1.06 : 1.01;
}
createMutation.mutate(data);
createMutation.mutate(formData);
};
const handleEdit = (provider: UpstreamProvider) => {
@@ -529,7 +160,6 @@ export default function ProvidersPage() {
api_key: '',
api_version: provider.api_version || null,
enabled: provider.enabled,
provider_fee: provider.provider_fee,
});
setIsEditDialogOpen(true);
};
@@ -541,7 +171,6 @@ export default function ProvidersPage() {
base_url: formData.base_url,
api_version: formData.api_version,
enabled: formData.enabled,
provider_fee: formData.provider_fee,
};
if (formData.api_key) {
updateData.api_key = formData.api_key;
@@ -570,16 +199,6 @@ export default function ProvidersPage() {
return providerType?.platform_url || null;
};
const canCreateAccount = (type: string) => {
const providerType = providerTypes.find((pt) => pt.id === type);
return providerType?.can_create_account || false;
};
const canShowBalance = (type: string) => {
const providerType = providerTypes.find((pt) => pt.id === type);
return providerType?.can_show_balance || false;
};
const toggleProviderExpansion = (providerId: number) => {
const newExpanded = new Set(expandedProviders);
if (newExpanded.has(providerId)) {
@@ -595,33 +214,6 @@ export default function ProvidersPage() {
}
};
const handleAddModel = (providerId: number) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'create',
initialData: null,
});
};
const handleEditModel = (providerId: number, model: AdminModel) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'edit',
initialData: model,
});
};
const handleOverrideModel = (providerId: number, model: AdminModel) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'override',
initialData: model,
});
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
@@ -661,12 +253,11 @@ export default function ProvidersPage() {
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData((prev) => ({
...prev,
setFormData({
...formData,
provider_type: value,
base_url: getDefaultBaseUrl(value),
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
}));
});
}}
>
<SelectTrigger>
@@ -701,30 +292,15 @@ export default function ProvidersPage() {
<div className='grid gap-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='api_key'>API Key</Label>
{canCreateAccount(formData.provider_type) ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={handleCreateAccount}
disabled={isCreatingAccount}
className='h-6 text-xs'
{getPlatformUrl(formData.provider_type) && (
<a
href={getPlatformUrl(formData.provider_type)!}
target='_blank'
rel='noopener noreferrer'
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
>
{isCreatingAccount
? 'Creating...'
: 'Create Account'}
</Button>
) : (
getPlatformUrl(formData.provider_type) && (
<a
href={getPlatformUrl(formData.provider_type)!}
target='_blank'
rel='noopener noreferrer'
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
>
Get Your API Key Here
</a>
)
Get Your API Key Here
</a>
)}
</div>
<Input
@@ -763,32 +339,6 @@ export default function ProvidersPage() {
/>
<Label htmlFor='enabled'>Enabled</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor='provider_fee'>
Provider Fee (Multiplier)
</Label>
<Input
id='provider_fee'
type='number'
step='0.001'
min='1.0'
value={formData.provider_fee || ''}
onChange={(e) =>
setFormData({
...formData,
provider_fee: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder={getProviderFeePlaceholder(
formData.provider_type
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
<DialogFooter>
<Button
@@ -860,16 +410,7 @@ export default function ProvidersPage() {
{provider.base_url}
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
{canShowBalance(provider.provider_type) &&
provider.api_key && (
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
)}
/>
)}
<div className='grid grid-cols-3 gap-2 sm:flex sm:flex-nowrap'>
<Button
variant='outline'
size='sm'
@@ -932,21 +473,9 @@ export default function ProvidersPage() {
// No provided models - show custom models directly without tabs
<div className='space-y-2'>
{providerModels.db_models.length === 0 ? (
<div className='flex flex-col items-center justify-center gap-2 py-4'>
<div className='text-muted-foreground text-sm'>
No models configured. Add custom models
to use this provider.
</div>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
<div className='text-muted-foreground py-4 text-center text-sm'>
No models configured. Add custom models to
use this provider.
</div>
) : (
<div className='space-y-2'>
@@ -977,24 +506,9 @@ export default function ProvidersPage() {
{model.description || model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
))}
@@ -1045,24 +559,12 @@ export default function ProvidersPage() {
value='custom'
className='mt-4 space-y-2'
>
<div className='flex items-center justify-between'>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground text-sm'>
Custom models override or extend the
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>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground mb-3 text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
@@ -1098,24 +600,9 @@ export default function ProvidersPage() {
model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
)
@@ -1150,25 +637,9 @@ export default function ProvidersPage() {
model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
handleOverrideModel(
provider.id,
model
)
}
>
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
)
@@ -1203,12 +674,11 @@ export default function ProvidersPage() {
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData((prev) => ({
...prev,
setFormData({
...formData,
provider_type: value,
base_url: getDefaultBaseUrl(value),
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
}));
});
}}
>
<SelectTrigger>
@@ -1292,32 +762,6 @@ export default function ProvidersPage() {
/>
<Label htmlFor='edit_enabled'>Enabled</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit_provider_fee'>
Provider Fee (Multiplier)
</Label>
<Input
id='edit_provider_fee'
type='number'
step='0.001'
min='1.0'
value={formData.provider_fee || ''}
onChange={(e) =>
setFormData({
...formData,
provider_fee: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder={getProviderFeePlaceholder(
formData.provider_type
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
<DialogFooter>
<Button
@@ -1335,23 +779,6 @@ export default function ProvidersPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-models', modelDialogState.providerId],
});
}}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
/>
)}
</SidebarInset>
</SidebarProvider>
);
-956
View File
@@ -1,956 +0,0 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Loader2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
const listFromString = (value: string): string[] =>
value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
const listToString = (value: string[] | undefined | null): string =>
value && value.length > 0 ? value.join(', ') : '';
const FormSchema = z.object({
id: z.string().min(1, 'Model ID is required'),
name: z.string().min(1, 'Name is required'),
description: z.string().default(''),
context_length: z.coerce.number().min(0).default(8192),
modality: z.string().min(1, 'Modality is required'),
input_modalities_raw: z.string().default(''),
output_modalities_raw: z.string().default(''),
tokenizer: z.string().default(''),
instruct_type: z.string().default(''),
canonical_slug: z.string().default(''),
alias_ids_raw: z.string().default(''),
upstream_provider_id: z.string().default(''),
input_cost: z.coerce.number().min(0).default(0),
output_cost: z.coerce.number().min(0).default(0),
request_cost: z.coerce.number().min(0).default(0),
image_cost: z.coerce.number().min(0).default(0),
web_search_cost: z.coerce.number().min(0).default(0),
internal_reasoning_cost: z.coerce.number().min(0).default(0),
max_prompt_cost: z.coerce.number().min(0).default(0),
max_completion_cost: z.coerce.number().min(0).default(0),
max_cost: z.coerce.number().min(0).default(0),
per_request_limits_raw: z.string().default(''),
top_provider_context_length: z.coerce.number().min(0).optional(),
top_provider_max_completion_tokens: z.coerce.number().min(0).optional(),
top_provider_is_moderated: z.boolean().default(false),
enabled: z.boolean().default(true),
});
type FormData = z.output<typeof FormSchema>;
export interface AddProviderModelDialogProps {
providerId: number;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: AdminModel | null;
mode?: 'create' | 'edit' | 'override';
}
export function AddProviderModelDialog({
providerId,
isOpen,
onClose,
onSuccess,
initialData,
mode = 'create',
}: AddProviderModelDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPresetOpen, setIsPresetOpen] = useState(false);
const [selectedPresetLabel, setSelectedPresetLabel] =
useState('Select a preset');
const form = useForm<FormData>({
resolver: zodResolver(FormSchema) as never,
defaultValues: {
id: '',
name: '',
description: '',
context_length: 8192,
modality: 'text',
input_modalities_raw: 'text',
output_modalities_raw: 'text',
tokenizer: '',
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
internal_reasoning_cost: 0,
max_prompt_cost: 0,
max_completion_cost: 0,
max_cost: 0,
per_request_limits_raw: '',
top_provider_context_length: undefined,
top_provider_max_completion_tokens: undefined,
top_provider_is_moderated: false,
enabled: true,
},
});
const isOverride = useMemo(() => mode === 'override', [mode]);
const isEdit = useMemo(() => mode === 'edit', [mode]);
const { data: presets = [], isLoading: isLoadingPresets } = useQuery({
queryKey: ['openrouter-presets'],
queryFn: () => AdminService.getOpenRouterPresets(),
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (initialData) {
const architecture = initialData.architecture as Record<string, unknown>;
const pricing = initialData.pricing as Record<string, number>;
const topProvider = initialData.top_provider as Record<
string,
unknown
> | null;
form.reset({
id: initialData.id,
name: initialData.name,
description: initialData.description,
context_length: initialData.context_length,
modality:
typeof architecture?.modality === 'string'
? architecture.modality
: 'text',
input_modalities_raw: listToString(
(architecture?.input_modalities as string[]) || []
),
output_modalities_raw: listToString(
(architecture?.output_modalities as string[]) || []
),
tokenizer:
typeof architecture?.tokenizer === 'string'
? architecture.tokenizer
: '',
instruct_type:
typeof architecture?.instruct_type === 'string'
? architecture.instruct_type
: '',
canonical_slug: initialData.canonical_slug || '',
alias_ids_raw: listToString(initialData.alias_ids),
upstream_provider_id:
typeof initialData.upstream_provider_id === 'string'
? initialData.upstream_provider_id
: initialData.upstream_provider_id?.toString() || '',
input_cost: pricing?.prompt ?? 0,
output_cost: pricing?.completion ?? 0,
request_cost: pricing?.request ?? 0,
image_cost: pricing?.image ?? 0,
web_search_cost: pricing?.web_search ?? 0,
internal_reasoning_cost: pricing?.internal_reasoning ?? 0,
max_prompt_cost: pricing?.max_prompt_cost ?? 0,
max_completion_cost: pricing?.max_completion_cost ?? 0,
max_cost: pricing?.max_cost ?? 0,
per_request_limits_raw: initialData.per_request_limits
? JSON.stringify(initialData.per_request_limits, null, 2)
: '',
top_provider_context_length:
typeof topProvider?.context_length === 'number'
? topProvider.context_length
: undefined,
top_provider_max_completion_tokens:
typeof topProvider?.max_completion_tokens === 'number'
? topProvider.max_completion_tokens
: undefined,
top_provider_is_moderated:
typeof topProvider?.is_moderated === 'boolean'
? topProvider.is_moderated
: false,
enabled: initialData.enabled,
});
} else {
form.reset({
id: '',
name: '',
description: '',
context_length: 8192,
modality: 'text',
input_modalities_raw: 'text',
output_modalities_raw: 'text',
tokenizer: '',
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
internal_reasoning_cost: 0,
max_prompt_cost: 0,
max_completion_cost: 0,
max_cost: 0,
per_request_limits_raw: '',
top_provider_context_length: undefined,
top_provider_max_completion_tokens: undefined,
top_provider_is_moderated: false,
enabled: true,
});
}
}, [initialData, form, isOpen]);
const applyModelToForm = (model: AdminModel) => {
setSelectedPresetLabel(`${model.id}${model.name}`);
const architecture = model.architecture as Record<string, unknown>;
const pricing = model.pricing as Record<string, number>;
const topProvider = model.top_provider as Record<string, unknown> | null;
form.setValue('id', model.id);
form.setValue('name', model.name);
form.setValue('description', model.description || '');
form.setValue('context_length', model.context_length);
form.setValue(
'modality',
typeof architecture?.modality === 'string'
? architecture.modality
: 'text'
);
form.setValue(
'input_modalities_raw',
listToString((architecture?.input_modalities as string[]) || [])
);
form.setValue(
'output_modalities_raw',
listToString((architecture?.output_modalities as string[]) || [])
);
form.setValue(
'tokenizer',
typeof architecture?.tokenizer === 'string' ? architecture.tokenizer : ''
);
form.setValue(
'instruct_type',
typeof architecture?.instruct_type === 'string'
? architecture.instruct_type
: ''
);
form.setValue('canonical_slug', model.canonical_slug || '');
form.setValue('alias_ids_raw', listToString(model.alias_ids));
form.setValue(
'upstream_provider_id',
typeof model.upstream_provider_id === 'string'
? model.upstream_provider_id
: model.upstream_provider_id?.toString() || ''
);
form.setValue('input_cost', pricing?.prompt ?? 0);
form.setValue('output_cost', pricing?.completion ?? 0);
form.setValue('request_cost', pricing?.request ?? 0);
form.setValue('image_cost', pricing?.image ?? 0);
form.setValue('web_search_cost', pricing?.web_search ?? 0);
form.setValue('internal_reasoning_cost', pricing?.internal_reasoning ?? 0);
form.setValue('max_prompt_cost', pricing?.max_prompt_cost ?? 0);
form.setValue('max_completion_cost', pricing?.max_completion_cost ?? 0);
form.setValue('max_cost', pricing?.max_cost ?? 0);
form.setValue(
'per_request_limits_raw',
model.per_request_limits
? JSON.stringify(model.per_request_limits, null, 2)
: ''
);
form.setValue(
'top_provider_context_length',
typeof topProvider?.context_length === 'number'
? topProvider.context_length
: undefined
);
form.setValue(
'top_provider_max_completion_tokens',
typeof topProvider?.max_completion_tokens === 'number'
? topProvider.max_completion_tokens
: undefined
);
form.setValue(
'top_provider_is_moderated',
typeof topProvider?.is_moderated === 'boolean'
? topProvider.is_moderated
: false
);
form.setValue('enabled', model.enabled);
};
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
let perRequestLimits: Record<string, unknown> | null = null;
if (
data.per_request_limits_raw &&
data.per_request_limits_raw.trim().length
) {
try {
perRequestLimits = JSON.parse(data.per_request_limits_raw);
} catch {
toast.error('Per-request limits must be valid JSON');
setIsSubmitting(false);
return;
}
}
const adminModel: AdminModel = {
id: data.id,
name: data.name,
description: data.description || '',
created: Math.floor(Date.now() / 1000),
context_length: data.context_length,
architecture: {
modality: data.modality,
input_modalities: listFromString(
data.input_modalities_raw || data.modality
),
output_modalities: listFromString(
data.output_modalities_raw || data.modality
),
tokenizer: data.tokenizer || '',
instruct_type: data.instruct_type?.trim() || null,
},
pricing: {
prompt: data.input_cost,
completion: data.output_cost,
request: data.request_cost,
image: data.image_cost,
web_search: data.web_search_cost,
internal_reasoning: data.internal_reasoning_cost,
max_prompt_cost: data.max_prompt_cost,
max_completion_cost: data.max_completion_cost,
max_cost: data.max_cost,
},
per_request_limits: perRequestLimits,
top_provider:
data.top_provider_context_length ||
data.top_provider_max_completion_tokens ||
data.top_provider_is_moderated
? {
context_length: data.top_provider_context_length ?? null,
max_completion_tokens:
data.top_provider_max_completion_tokens ?? null,
is_moderated: data.top_provider_is_moderated,
}
: null,
upstream_provider_id: data.upstream_provider_id?.trim().length
? data.upstream_provider_id.trim()
: providerId,
canonical_slug: data.canonical_slug?.trim() || null,
alias_ids: listFromString(data.alias_ids_raw || ''),
enabled: data.enabled,
};
if (isEdit) {
await AdminService.updateProviderModel(providerId, data.id, adminModel);
toast.success('Model updated successfully');
} else {
await AdminService.createProviderModel(providerId, adminModel);
toast.success(
isOverride ? 'Model override created' : 'Model created successfully'
);
}
onSuccess();
onClose();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Unknown error saving model';
toast.error(`Failed to save model: ${message}`);
} finally {
setIsSubmitting(false);
}
};
const title = isEdit
? 'Edit Model'
: isOverride
? 'Override Model'
: 'Add Custom Model';
const description = isOverride
? 'Create a custom override for this upstream model'
: 'Add a new model configuration for this provider';
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[720px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-4 w-4' />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Presets</div>
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
<Popover open={isPresetOpen} onOpenChange={setIsPresetOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={isPresetOpen}
className='w-full justify-between overflow-hidden text-left text-sm'
>
<span className='truncate'>
{isLoadingPresets
? 'Loading presets...'
: selectedPresetLabel}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className='w-80 max-w-sm p-0'
align='start'
sideOffset={4}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={true}>
<CommandInput placeholder='Search presets...' />
<CommandList
className='max-h-64 overflow-y-auto overscroll-contain'
onWheel={(e) => e.stopPropagation()}
>
{isLoadingPresets ? (
<CommandEmpty>Loading presets...</CommandEmpty>
) : presets.length === 0 ? (
<CommandEmpty>No presets available.</CommandEmpty>
) : (
<CommandGroup heading='Presets'>
{presets.map((preset) => (
<CommandItem
key={preset.id}
value={preset.id}
onSelect={() => {
applyModelToForm(preset);
setIsPresetOpen(false);
}}
>
<div className='flex flex-col text-sm'>
<span className='font-medium'>{preset.id}</span>
<span className='text-muted-foreground text-xs'>
{preset.name}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
</div>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='id'
render={({ field }) => (
<FormItem>
<FormLabel>Model ID *</FormLabel>
<FormControl>
<Input
placeholder='e.g., gpt-5.1'
{...field}
disabled={isOverride || isEdit}
/>
</FormControl>
<FormDescription>
Unique identifier for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Display Name *</FormLabel>
<FormControl>
<Input placeholder='e.g., GPT-5.1' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Brief description...'
{...field}
rows={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='modality'
render={({ field }) => (
<FormItem>
<FormLabel>Modality *</FormLabel>
<FormControl>
<Input
placeholder='text, text+image->text, image->text, etc.'
{...field}
/>
</FormControl>
<FormDescription>
Composite modality label (e.g., text+image-&gt;text)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input type='number' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_modalities_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Input Modalities</FormLabel>
<FormControl>
<Input placeholder='text, image, file' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_modalities_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Output Modalities</FormLabel>
<FormControl>
<Input placeholder='text, image' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='tokenizer'
render={({ field }) => (
<FormItem>
<FormLabel>Tokenizer</FormLabel>
<FormControl>
<Input placeholder='e.g., GPT, tiktoken' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='instruct_type'
render={({ field }) => (
<FormItem>
<FormLabel>Instruct Type</FormLabel>
<FormControl>
<Input placeholder='e.g., chat, completion' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='canonical_slug'
render={({ field }) => (
<FormItem>
<FormLabel>Canonical Slug</FormLabel>
<FormControl>
<Input
placeholder='google/gemini-3-pro-preview-20251117'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='alias_ids_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Alias IDs</FormLabel>
<FormControl>
<Input placeholder='alias-1, alias-2' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='upstream_provider_id'
render={({ field }) => (
<FormItem>
<FormLabel>Upstream Provider ID</FormLabel>
<FormControl>
<Input placeholder='gemini' {...field} />
</FormControl>
<FormDescription>
Defaults to current provider if left blank
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='per_request_limits_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Per-request Limits (JSON)</FormLabel>
<FormControl>
<Textarea
placeholder='{"requests_per_min": 60}'
{...field}
rows={4}
/>
</FormControl>
<FormDescription>
JSON object; leave empty for none
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>
Pricing (USD per 1M tokens)
</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Input Cost</FormLabel>
<FormControl>
<Input type='number' step='0.01' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Output Cost</FormLabel>
<FormControl>
<Input type='number' step='0.01' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='request_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Per Request Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='image_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Image Cost (per image)</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='web_search_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Web Search Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='internal_reasoning_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Internal Reasoning Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_prompt_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Prompt Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_completion_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Total Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>Top Provider (optional)</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3'>
<FormField
control={form.control}
name='top_provider_context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input
type='number'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='top_provider_max_completion_tokens'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Tokens</FormLabel>
<FormControl>
<Input
type='number'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='top_provider_is_moderated'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
Is Moderated
</FormLabel>
<FormDescription>
Whether provider enforces moderation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Enabled</FormLabel>
<FormDescription>Enable this model for use</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{isEdit
? 'Save Changes'
: isOverride
? 'Create Override'
: 'Create Model'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+70 -151
View File
@@ -2,14 +2,15 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
import {
AdminService,
type AdminModelGroup,
type AdminModel,
} from '@/lib/api/services/admin';
type Model,
type ManualModel,
type GroupSettings,
} from '@/lib/api/schemas/models';
import { AdminService, type AdminModelGroup } from '@/lib/api/services/admin';
type ModelGroup = AdminModelGroup;
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { AddModelForm } from '@/components/AddModelForm';
import { EditModelForm } from '@/components/EditModelForm';
import { EditGroupForm } from '@/components/EditGroupForm';
import { CollectModelsDialog } from '@/components/CollectModelsDialog';
import { formatCost } from '@/lib/services/costValidation';
@@ -79,23 +80,14 @@ export function ModelSelector({
}: ModelSelectorProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [, setHoveredModelId] = useState<string | null>(null);
const [isAddFormOpen, setIsAddFormOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | null>(null);
const [editingGroup, setEditingGroup] = useState<{
provider: string;
models: Model[];
groupData: ModelGroup;
} | null>(null);
const [isCollectDialogOpen, setIsCollectDialogOpen] = useState(false);
const [modelDialogState, setModelDialogState] = useState<{
isOpen: boolean;
providerId: number | null;
mode: 'create' | 'edit' | 'override';
initialData?: AdminModel | null;
}>({
isOpen: false,
providerId: null,
mode: 'create',
initialData: null,
});
// Bulk selection state
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
@@ -500,104 +492,44 @@ export function ModelSelector({
return hasIndividualApiKey || hasIndividualUrl;
};
const handleAddModelClick = (providerId: number) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'create',
initialData: null,
});
};
// Handle manual model addition
const handleAddModel = async (newModel: ManualModel) => {
try {
// Find or create the provider group
let providerGroup = groups.find((g) => g.provider === newModel.provider);
const handleEditModelClick = (model: Model) => {
if (!model.provider_id) {
toast.error('Provider ID missing for model');
return;
if (!providerGroup) {
providerGroup = await AdminService.createModelGroup({
provider: newModel.provider,
group_api_key: undefined,
});
}
const modelData = {
name: newModel.name,
full_name: newModel.name,
input_cost: newModel.input_cost,
output_cost: newModel.output_cost,
provider: newModel.provider,
modelType: newModel.modelType,
description: newModel.description || undefined,
contextLength: newModel.contextLength,
};
await AdminService.createModel(modelData);
await refetchModels();
toast.success(`Model "${newModel.name}" added successfully!`);
} catch (error) {
console.error('Error adding model:', error);
toast.error('Failed to add model. Please try again.');
throw error; // Re-throw to let the form handle the error
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
name: model.name,
description: model.description || '',
created: new Date(model.createdAt).getTime() / 1000,
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
};
setModelDialogState({
isOpen: true,
providerId,
mode: 'edit',
initialData: adminModel,
});
};
const handleOverrideModelClick = (model: Model) => {
if (!model.provider_id) {
toast.error('Provider ID missing for model');
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
name: model.name,
description: model.description || '',
created: new Date(model.createdAt).getTime() / 1000,
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
};
setModelDialogState({
isOpen: true,
providerId,
mode: 'override',
initialData: adminModel,
});
};
// Handle model update
const handleModelUpdate = async () => {
await refetchModels();
setEditingModel(null);
};
// Handle group update
@@ -900,18 +832,11 @@ export function ModelSelector({
Delete All Overrides Permanently
</Button>
)}
{/* Model Management Actions */}
{groupData && (
<Button
onClick={() => handleAddModelClick(parseInt(groupData.id))}
className='gap-2'
variant='outline'
>
<CheckSquare className='h-4 w-4' />
Add Custom Model
</Button>
)}
{/*
{/* Model Management Actions
<Button onClick={() => setIsAddFormOpen(true)} className='gap-2'>
<Plus className='h-4 w-4' />
Add Model
</Button>
<Button
onClick={() => setIsCollectDialogOpen(true)}
variant='outline'
@@ -1155,28 +1080,15 @@ export function ModelSelector({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{model.api_key_type !== 'remote' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEditModelClick(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
)}
{model.api_key_type === 'remote' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleOverrideModelClick(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Override
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setEditingModel(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
<DropdownMenuSeparator />
{model.soft_deleted ? (
<DropdownMenuItem
@@ -1303,16 +1215,23 @@ export function ModelSelector({
})}
{/* Forms and Dialogs */}
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
<AddModelForm
isOpen={isAddFormOpen}
onModelAdd={handleAddModel}
onCancel={() => setIsAddFormOpen(false)}
/>
{editingModel && (
<EditModelForm
model={editingModel}
providerId={
editingModel.provider_id
? parseInt(editingModel.provider_id)
: undefined
}
onSuccess={handleModelUpdate}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
onModelUpdate={handleModelUpdate}
onCancel={() => setEditingModel(null)}
isOpen={!!editingModel}
/>
)}
+7 -12
View File
@@ -48,26 +48,20 @@ export function CurrencyToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
>
<Coins className='h-4 w-4' />
<span className='hidden sm:inline-block'>
{getLabel(displayUnit)}
</span>
<span className='uppercase sm:hidden'>{displayUnit}</span>
<Button variant="ghost" size="sm" className="h-9 w-9 px-0 gap-2 w-auto px-3 font-normal">
<Coins className="h-4 w-4" />
<span className="hidden sm:inline-block">{getLabel(displayUnit)}</span>
<span className="sm:hidden uppercase">{displayUnit}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
Millisatoshis (mSAT)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
Satoshis (sat)
</DropdownMenuItem>
<DropdownMenuItem
<DropdownMenuItem
onClick={() => setDisplayUnit('usd')}
disabled={!usdPerSat}
>
@@ -77,3 +71,4 @@ export function CurrencyToggle() {
</DropdownMenu>
);
}
@@ -96,3 +96,4 @@ export function DashboardBalanceSummary({
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
<CardTitle>Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground py-8 text-center'>
<p className='text-muted-foreground text-center py-8'>
No errors found in the selected time period
</p>
</CardContent>
-284
View File
@@ -1,284 +0,0 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
msats?: string;
};
interface ApiKeyManagerProps {
baseUrl: string;
apiKey?: string;
walletInfo?: WalletSnapshot | null;
onApiKeyChanged?: (apiKey: string) => void;
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
onRefundComplete?: (receipt: RefundReceipt) => void;
}
async function fetchWalletInfo(
baseUrl: string,
apiKey: string
): Promise<WalletSnapshot> {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
reserved?: number;
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
};
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
export function ApiKeyManager({
baseUrl,
apiKey = '',
walletInfo = null,
onApiKeyChanged,
onWalletInfoUpdated,
onRefundComplete,
}: ApiKeyManagerProps): JSX.Element {
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
}
if (typeof navigator === 'undefined' || !navigator.clipboard) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
}
}, []);
const handleSyncBalance = useCallback(async (): Promise<void> => {
const activeApiKey = apiKeyInput.trim();
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsSyncingBalance(true);
try {
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onWalletInfoUpdated?.(snapshot);
toast.success('Balance synced');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to sync balance'
);
} finally {
setIsSyncingBalance(false);
}
}, [apiKeyInput, baseUrl, onWalletInfoUpdated]);
const handleRefund = useCallback(async (): Promise<void> => {
const activeApiKey = apiKeyInput.trim();
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${activeApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const receipt = (await response.json()) as RefundReceipt;
onRefundComplete?.(receipt);
onWalletInfoUpdated?.(null);
setApiKeyInput('');
toast.success('Refund completed');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [apiKeyInput, baseUrl, onRefundComplete, onWalletInfoUpdated]);
const handleApiKeyChange = useCallback(
(newKey: string) => {
setApiKeyInput(newKey);
onApiKeyChanged?.(newKey);
if (newKey !== apiKey) {
onWalletInfoUpdated?.(null);
}
},
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
);
const activeApiKey = apiKeyInput.trim();
const showManageDetails =
hasInteractedManage || Boolean(walletInfo) || activeApiKey.length > 0;
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<RefreshCcw className='text-primary h-5 w-5' />
API Key Management
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
Manage your existing API keys and balances
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>Manage existing key</span>
{walletInfo && (
<span className='text-primary'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(event) => handleApiKeyChange(event.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10'
onClick={() => handleCopy(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isSyncingBalance || !activeApiKey}
>
<RefreshCcw className='h-4 w-4' />
Sync
</Button>
</div>
</div>
{showManageDetails && (
<div className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
<Separator />
<div className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>Refund remaining balance</span>
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding || !activeApiKey}
variant='destructive'
className='gap-2'
>
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing...' : 'Refund & Delete Key'}
</Button>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
</div>
</div>
)}
</section>
</CardContent>
</Card>
);
}
@@ -1,452 +0,0 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import { Copy, KeyRound, RefreshCcw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
msats?: string;
};
interface CashuPaymentWorkflowProps {
baseUrl: string;
apiKey?: string;
walletInfo?: WalletSnapshot | null;
onApiKeyCreated?: (apiKey: string, walletInfo: WalletSnapshot) => void;
onApiKeyChanged?: (apiKey: string) => void;
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
onRefundComplete?: (receipt: RefundReceipt) => void;
}
async function fetchWalletInfo(
baseUrl: string,
apiKey: string
): Promise<WalletSnapshot> {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
reserved?: number;
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
};
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
export function CashuPaymentWorkflow({
baseUrl,
apiKey = '',
walletInfo = null,
onApiKeyCreated,
onApiKeyChanged,
onWalletInfoUpdated,
onRefundComplete,
}: CashuPaymentWorkflowProps): JSX.Element {
const [initialToken, setInitialToken] = useState('');
const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isCreatingKey, setIsCreatingKey] = useState(false);
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 activeApiKey = apiKeyInput.trim();
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
}
if (typeof navigator === 'undefined' || !navigator.clipboard) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
}
}, []);
const handleCreateKey = useCallback(async (): Promise<void> => {
if (!initialToken.trim()) {
toast.error('Cashu token required');
return;
}
setIsCreatingKey(true);
try {
const params = new URLSearchParams({
initial_balance_token: initialToken.trim(),
});
const response = await fetch(
`${baseUrl}/v1/balance/create?${params.toString()}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to create API key');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
};
const snapshot: WalletSnapshot = {
apiKey: payload.api_key,
balanceMsats: payload.balance ?? 0,
reservedMsats: 0,
};
setApiKeyInput(snapshot.apiKey);
onApiKeyCreated?.(snapshot.apiKey, snapshot);
setInitialToken('');
toast.success('API key ready');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to create API key'
);
} finally {
setIsCreatingKey(false);
}
}, [initialToken, baseUrl, onApiKeyCreated]);
const handleSyncBalance = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsSyncingBalance(true);
try {
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onWalletInfoUpdated?.(snapshot);
toast.success('Balance synced');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to sync balance'
);
} finally {
setIsSyncingBalance(false);
}
}, [activeApiKey, baseUrl, onWalletInfoUpdated]);
const handleTopup = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
if (!topupToken.trim()) {
toast.error('Cashu token required for top-up');
return;
}
setIsTopupLoading(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/topup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${activeApiKey}`,
},
body: JSON.stringify({ cashu_token: topupToken.trim() }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to top up');
}
const payload = (await response.json()) as { msats: number };
toast.success(`Added ${formatSats(payload.msats)} sats`);
setTopupToken('');
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onApiKeyCreated?.(snapshot.apiKey, snapshot);
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Top-up failed');
} finally {
setIsTopupLoading(false);
}
}, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]);
const handleRefund = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${activeApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const payload = (await response.json()) as RefundReceipt;
onRefundComplete?.(payload);
onWalletInfoUpdated?.(null);
setApiKeyInput('');
toast.success('Refund requested');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [activeApiKey, baseUrl, onRefundComplete, onWalletInfoUpdated]);
const handleApiKeyChange = useCallback(
(newKey: string) => {
setApiKeyInput(newKey);
onApiKeyChanged?.(newKey);
if (newKey !== apiKey) {
onWalletInfoUpdated?.(null);
}
},
[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);
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='text-primary h-5 w-5' />
API key workflow
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
Sections expand as soon as you interact
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
)}
</header>
<Textarea
value={initialToken}
onChange={(event) => setInitialToken(event.target.value)}
placeholder='cashuA1...'
rows={showCreateDetails ? 4 : 2}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleCreateKey}
disabled={isCreatingKey}
className='gap-2'
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-muted-foreground text-xs'>
Redeems instantly and returns <code>sk-</code> key.
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(event) => handleApiKeyChange(event.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10'
onClick={() => handleCopy(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isSyncingBalance || !activeApiKey}
>
<RefreshCcw className='h-4 w-4' />
Sync
</Button>
</div>
</div>
{showManageDetails && (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>3 · Top up</span>
{showTopupDetails && (
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
{canTopup ? 'Ready to redeem' : 'Paste API key first'}
</span>
)}
</header>
<Textarea
value={topupToken}
onChange={(event) => setTopupToken(event.target.value)}
placeholder='cashuB1...'
rows={showTopupDetails ? 3 : 1}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedTopup(true)}
disabled={!canTopup}
/>
{showTopupDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleTopup}
disabled={isTopupLoading || !canTopup}
variant='outline'
className='gap-2'
>
{isTopupLoading ? 'Topping up…' : 'Top up this key'}
</Button>
<span className='text-muted-foreground text-xs'>
{canTopup ? (
<>
Adds balance to the same <code>sk-</code> token.
</>
) : (
'Enter your sk- key above to unlock top ups.'
)}
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>4 · Refund</span>
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding || !activeApiKey}
variant='destructive'
className='gap-2'
>
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
</section>
</CardContent>
</Card>
);
}
+422 -105
View File
@@ -2,18 +2,22 @@
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Bolt, Copy, RefreshCcw, ShieldCheck, Terminal } from 'lucide-react';
import {
Bolt,
Copy,
KeyRound,
RefreshCcw,
ShieldCheck,
Terminal,
} from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
import { ApiKeyManager } from './api-key-manager';
type NodeInfo = {
name: string;
@@ -58,6 +62,36 @@ async function fetchNodeInfo(baseUrl: string): Promise<NodeInfo> {
};
}
async function fetchWalletInfo(
baseUrl: string,
apiKey: string
): Promise<WalletSnapshot> {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
reserved?: number;
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
};
}
function normalizeBaseUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) {
@@ -66,15 +100,30 @@ function normalizeBaseUrl(url: string): string {
return trimmed.replace(/\/+$/, '');
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
export function CheatSheet(): JSX.Element {
const [baseUrl, setBaseUrl] = useState(() =>
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
);
const [initialToken, setInitialToken] = useState('');
const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(
null
);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(null);
const [isCreatingKey, setIsCreatingKey] = useState(false);
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);
useEffect(() => {
if (!baseUrl && typeof window !== 'undefined') {
@@ -87,6 +136,8 @@ export function CheatSheet(): JSX.Element {
[baseUrl]
);
const activeApiKey = apiKeyInput.trim();
const {
data: nodeInfo,
isLoading: isInfoLoading,
@@ -117,28 +168,141 @@ export function CheatSheet(): JSX.Element {
}
}, []);
const handleApiKeyCreated = useCallback(
(apiKey: string, snapshot: WalletSnapshot) => {
setApiKeyInput(apiKey);
const handleCreateKey = useCallback(async (): Promise<void> => {
if (!initialToken.trim()) {
toast.error('Cashu token required');
return;
}
setIsCreatingKey(true);
setRefundReceipt(null);
try {
const params = new URLSearchParams({
initial_balance_token: initialToken.trim(),
});
const response = await fetch(
`${normalizedBaseUrl}/v1/balance/create?${params.toString()}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to create API key');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
};
const snapshot: WalletSnapshot = {
apiKey: payload.api_key,
balanceMsats: payload.balance ?? 0,
reservedMsats: 0,
};
setApiKeyInput(snapshot.apiKey);
setWalletInfo(snapshot);
setRefundReceipt(null);
},
[]
);
setInitialToken('');
toast.success('API key ready');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to create API key'
);
} finally {
setIsCreatingKey(false);
}
}, [initialToken, normalizedBaseUrl]);
const handleApiKeyChanged = useCallback((apiKey: string) => {
setApiKeyInput(apiKey);
}, []);
const handleSyncBalance = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
const handleWalletInfoUpdated = useCallback((info: WalletSnapshot | null) => {
setWalletInfo(info);
}, []);
setIsSyncingBalance(true);
try {
const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey);
setWalletInfo(snapshot);
toast.success('Balance synced');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to sync balance'
);
} finally {
setIsSyncingBalance(false);
}
}, [activeApiKey, normalizedBaseUrl]);
const handleRefundComplete = useCallback((receipt: RefundReceipt) => {
setRefundReceipt(receipt);
setWalletInfo(null);
setApiKeyInput('');
}, []);
const handleTopup = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
if (!topupToken.trim()) {
toast.error('Cashu token required for top-up');
return;
}
setIsTopupLoading(true);
setRefundReceipt(null);
try {
const response = await fetch(`${normalizedBaseUrl}/v1/balance/topup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${activeApiKey}`,
},
body: JSON.stringify({ cashu_token: topupToken.trim() }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to top up');
}
const payload = (await response.json()) as { msats: number };
toast.success(`Added ${formatSats(payload.msats)} sats`);
setTopupToken('');
const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey);
setWalletInfo(snapshot);
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Top-up failed');
} finally {
setIsTopupLoading(false);
}
}, [activeApiKey, normalizedBaseUrl, topupToken]);
const handleRefund = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${normalizedBaseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${activeApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const payload = (await response.json()) as RefundReceipt;
setRefundReceipt(payload);
setWalletInfo(null);
toast.success('Refund requested');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [activeApiKey, normalizedBaseUrl]);
const handleRefreshInfo = useCallback(async (): Promise<void> => {
const result = await refetchNodeInfo();
@@ -150,7 +314,7 @@ export function CheatSheet(): JSX.Element {
}, [refetchNodeInfo]);
const curlSnippet = useMemo(() => {
const keyPreview = apiKeyInput || 'YOUR_API_KEY';
const keyPreview = activeApiKey || 'YOUR_API_KEY';
return [
`curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`,
` -H "Authorization: Bearer ${keyPreview}"`,
@@ -163,21 +327,27 @@ export function CheatSheet(): JSX.Element {
' ]',
" }'",
].join('\n');
}, [apiKeyInput, normalizedBaseUrl]);
}, [activeApiKey, normalizedBaseUrl]);
const showCreateDetails =
hasInteractedCreate || initialToken.trim().length > 0;
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails =
hasInteractedTopup || topupToken.trim().length > 0;
const refundToken = refundReceipt?.token ?? null;
const canTopup = Boolean(activeApiKey);
return (
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
<div className='min-h-screen bg-gradient-to-b from-background via-background to-muted'>
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
<section className='relative space-y-3 text-center md:text-left'>
<div className='absolute top-0 right-0 hidden md:block'>
<div className='absolute right-0 top-0 hidden md:block'>
<Button asChild size='sm' className='px-3 text-xs'>
<a href='/login'>Admin</a>
</Button>
</div>
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider uppercase'>
<Bolt className='text-primary h-4 w-4' />
<div className='inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] uppercase tracking-wider text-muted-foreground'>
<Bolt className='h-4 w-4 text-primary' />
Routstr cheat sheet
</div>
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
@@ -195,10 +365,10 @@ export function CheatSheet(): JSX.Element {
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='text-primary h-4 w-4' />
<ShieldCheck className='h-4 w-4 text-primary' />
Node identity
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
/v1/info snapshot
</p>
</div>
@@ -234,7 +404,7 @@ export function CheatSheet(): JSX.Element {
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
Version
</dt>
<dd className='text-base font-medium'>
@@ -242,7 +412,7 @@ export function CheatSheet(): JSX.Element {
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
@@ -251,7 +421,7 @@ export function CheatSheet(): JSX.Element {
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
@@ -261,10 +431,10 @@ export function CheatSheet(): JSX.Element {
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
npub
</dt>
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
<dd className='flex items-center gap-2 break-all text-sm font-mono'>
{nodeInfo.npub}
<Button
variant='ghost'
@@ -279,7 +449,7 @@ export function CheatSheet(): JSX.Element {
)}
</dl>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
@@ -308,10 +478,10 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Terminal className='text-primary h-4 w-4' />
<Terminal className='h-4 w-4 text-primary' />
Quick docs
</CardTitle>
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
<span className='text-xs uppercase tracking-wide text-muted-foreground'>
curl-ready
</span>
</CardHeader>
@@ -332,10 +502,8 @@ export function CheatSheet(): JSX.Element {
<Copy className='h-4 w-4' />
</Button>
</div>
<div className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
<pre className='break-all whitespace-pre-wrap'>
{curlSnippet}
</pre>
<div className='rounded-lg bg-muted p-4 font-mono text-sm leading-6'>
<pre className='whitespace-pre-wrap break-all'>{curlSnippet}</pre>
</div>
<div className='flex gap-2'>
<Button
@@ -361,69 +529,218 @@ export function CheatSheet(): JSX.Element {
</Card>
</section>
<Tabs defaultValue='cashu' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
</TabsList>
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='h-5 w-5 text-primary' />
API key workflow
</CardTitle>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
Sections expand as soon as you interact
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
)}
</header>
<Textarea
value={initialToken}
onChange={(event) => setInitialToken(event.target.value)}
placeholder='cashuA1...'
rows={showCreateDetails ? 4 : 2}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleCreateKey}
disabled={isCreatingKey}
className='gap-2'
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-xs text-muted-foreground'>
Redeems instantly and returns <code>sk-</code> key.
</span>
</div>
)}
</section>
<TabsContent value='cashu' className='space-y-4'>
<CashuPaymentWorkflow
baseUrl={normalizedBaseUrl}
onApiKeyCreated={handleApiKeyCreated}
/>
</TabsContent>
<Separator />
<TabsContent value='lightning' className='space-y-4'>
<LightningPaymentWorkflow
baseUrl={normalizedBaseUrl}
onApiKeyCreated={handleApiKeyCreated}
/>
</TabsContent>
<TabsContent value='manage' className='space-y-4'>
<ApiKeyManager
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
walletInfo={walletInfo}
onApiKeyChanged={handleApiKeyChanged}
onWalletInfoUpdated={handleWalletInfoUpdated}
onRefundComplete={handleRefundComplete}
/>
{refundToken && (
<Card>
<CardHeader>
<CardTitle className='text-lg'>Refund Complete</CardTitle>
</CardHeader>
<CardContent className='space-y-2'>
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>Cashu refund token</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(refundToken)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={refundToken}
readOnly
rows={4}
className='font-mono text-xs'
/>
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(event) => {
setApiKeyInput(event.target.value);
setWalletInfo(null);
}}
placeholder='sk-...'
className='font-mono text-sm'
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10'
onClick={() => handleCopy(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isSyncingBalance || !activeApiKey}
>
<RefreshCcw className='h-4 w-4' />
Sync
</Button>
</div>
</div>
{showManageDetails && (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
<div className='rounded-lg border p-3'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>3 · Top up</span>
{showTopupDetails && (
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
{canTopup ? 'Ready to redeem' : 'Paste API key first'}
</span>
)}
</header>
<Textarea
value={topupToken}
onChange={(event) => setTopupToken(event.target.value)}
placeholder='cashuB1...'
rows={showTopupDetails ? 3 : 1}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedTopup(true)}
disabled={!canTopup}
/>
{showTopupDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleTopup}
disabled={isTopupLoading || !canTopup}
variant='outline'
className='gap-2'
>
{isTopupLoading ? 'Topping up…' : 'Top up this key'}
</Button>
<span className='text-xs text-muted-foreground'>
{canTopup
? (
<>
Adds balance to the same <code>sk-</code> token.
</>
)
: 'Enter your sk- key above to unlock top ups.'}
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>4 · Refund</span>
{refundReceipt && <span className='text-primary'>Done</span>}
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding}
variant='destructive'
className='gap-2'
>
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-xs text-muted-foreground'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
{refundToken && (
<div className='space-y-2 rounded-lg border bg-muted/30 p-4'>
<div className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>Cashu refund token</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(refundToken)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={refundToken}
readOnly
rows={4}
className='font-mono text-xs'
/>
</div>
)}
</section>
</CardContent>
</Card>
</main>
</div>
);
}
@@ -1,685 +0,0 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import Image from 'next/image';
import { Copy, Zap, CheckCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import QRCode from 'qrcode';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type LightningInvoice = {
invoice_id: string;
bolt11: string;
amount_sats: number;
expires_at: number;
payment_hash: string;
};
type InvoiceStatus = {
status: string;
api_key?: string;
amount_sats: number;
paid_at?: number;
created_at: number;
expires_at: number;
};
interface LightningPaymentWorkflowProps {
baseUrl: string;
onApiKeyCreated?: (apiKey: string, walletInfo: WalletSnapshot) => void;
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
async function generateQRCodeSVG(text: string): Promise<string> {
try {
return await QRCode.toDataURL(text, {
type: 'image/png',
width: 200,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
} catch (error) {
console.error('Failed to generate QR code:', error);
return '';
}
}
export function LightningPaymentWorkflow({
baseUrl,
onApiKeyCreated,
}: LightningPaymentWorkflowProps): JSX.Element {
const [createAmount, setCreateAmount] = useState<string>('');
const [topupAmount, setTopupAmount] = useState<string>('');
const [topupApiKey, setTopupApiKey] = useState<string>('');
const [recoverInvoice, setRecoverInvoice] = useState<string>('');
const [createInvoice, setCreateInvoice] = useState<LightningInvoice | null>(
null
);
const [topupInvoice, setTopupInvoice] = useState<LightningInvoice | null>(
null
);
const [createQRCode, setCreateQRCode] = useState<string>('');
const [topupQRCode, setTopupQRCode] = useState<string>('');
const [createdApiKey, setCreatedApiKey] = useState<string>('');
const [topupApiKeyResult, setTopupApiKeyResult] = useState<string>('');
const [recoveredApiKey, setRecoveredApiKey] = useState<string>('');
const [isWaitingPayment, setIsWaitingPayment] = useState(false);
const [isWaitingTopupPayment, setIsWaitingTopupPayment] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isTopupping, setIsTopupping] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) return;
if (typeof navigator === 'undefined' || !navigator.clipboard) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
}
}, []);
const pollInvoiceStatus = useCallback(
async (invoiceId: string, onPaid: (status: InvoiceStatus) => void) => {
let attempts = 0;
const maxAttempts = 60; // 5 minutes with 5 second intervals
const poll = async () => {
try {
const response = await fetch(
`${baseUrl}/v1/balance/lightning/invoice/${invoiceId}/status`
);
if (!response.ok) {
throw new Error('Failed to check invoice status');
}
const status: InvoiceStatus = await response.json();
if (status.status === 'paid' && status.api_key) {
onPaid(status);
return;
}
if (status.status === 'expired' || status.status === 'cancelled') {
toast.error('Invoice expired or cancelled');
return;
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000); // Poll every 5 seconds
} else {
toast.error('Payment timeout - please check manually');
}
} catch (error) {
console.error('Failed to poll invoice status:', error);
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000);
}
}
};
poll();
},
[baseUrl]
);
const handleCreateInvoice = useCallback(async (): Promise<void> => {
const amount = parseInt(createAmount);
if (!amount || amount <= 0) {
toast.error('Please enter a valid amount');
return;
}
setIsCreating(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount_sats: amount,
purpose: 'create',
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to create invoice');
}
const invoice: LightningInvoice = await response.json();
setCreateInvoice(invoice);
const qrCode = await generateQRCodeSVG(invoice.bolt11);
setCreateQRCode(qrCode);
setIsWaitingPayment(true);
toast.success('Lightning invoice created - waiting for payment...');
pollInvoiceStatus(invoice.invoice_id, (status) => {
if (status.api_key) {
const walletInfo: WalletSnapshot = {
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setCreatedApiKey(status.api_key);
setIsWaitingPayment(false);
toast.success('Payment received! API key created.');
setCreateInvoice(null);
setCreateAmount('');
setCreateQRCode('');
}
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to create invoice'
);
setIsWaitingPayment(false);
} finally {
setIsCreating(false);
}
}, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated]);
const handleTopupInvoice = useCallback(async (): Promise<void> => {
const amount = parseInt(topupAmount);
if (!amount || amount <= 0) {
toast.error('Please enter a valid amount');
return;
}
if (!topupApiKey.trim()) {
toast.error('Please enter your API key');
return;
}
setIsTopupping(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount_sats: amount,
purpose: 'topup',
api_key: topupApiKey.trim(),
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to create topup invoice');
}
const invoice: LightningInvoice = await response.json();
setTopupInvoice(invoice);
const qrCode = await generateQRCodeSVG(invoice.bolt11);
setTopupQRCode(qrCode);
setIsWaitingTopupPayment(true);
toast.success('Lightning topup invoice created - waiting for payment...');
pollInvoiceStatus(invoice.invoice_id, (status) => {
if (status.api_key) {
const walletInfo: WalletSnapshot = {
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setTopupApiKeyResult(status.api_key);
setIsWaitingTopupPayment(false);
toast.success(
`Payment received! Added ${formatSats(status.amount_sats * 1000)} sats.`
);
setTopupInvoice(null);
setTopupAmount('');
setTopupQRCode('');
}
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: 'Failed to create topup invoice'
);
setIsWaitingTopupPayment(false);
} finally {
setIsTopupping(false);
}
}, [topupAmount, topupApiKey, baseUrl, pollInvoiceStatus, onApiKeyCreated]);
const handleRecoverInvoice = useCallback(async (): Promise<void> => {
if (!recoverInvoice.trim()) {
toast.error('Please enter a BOLT11 invoice');
return;
}
setIsRecovering(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/lightning/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bolt11: recoverInvoice.trim(),
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Invoice not found or not paid');
}
const status: InvoiceStatus = await response.json();
if (status.status === 'paid' && status.api_key) {
const walletInfo: WalletSnapshot = {
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setRecoveredApiKey(status.api_key);
toast.success('API key recovered successfully!');
setRecoverInvoice('');
} else {
toast.error(`Invoice status: ${status.status}`);
}
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to recover invoice'
);
} finally {
setIsRecovering(false);
}
}, [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;
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<Zap className='text-primary h-5 w-5' />
Lightning Payment Workflow
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
Create and manage API keys using Lightning Network payments
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>1 · Create key with Lightning</span>
{showCreateDetails && (
<span className='text-primary'>Amount specified</span>
)}
</header>
<Input
type='number'
value={createAmount}
onChange={(event) => setCreateAmount(event.target.value)}
placeholder='Amount in sats (e.g., 1000)'
className='text-sm'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='space-y-3'>
<Button
onClick={handleCreateInvoice}
disabled={isCreating || !!createInvoice}
className='gap-2'
>
{isCreating
? 'Creating invoice...'
: 'Create Lightning Invoice'}
</Button>
{createInvoice && (
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>
Lightning Invoice ({createInvoice.amount_sats} sats)
</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(createInvoice.bolt11)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
{isWaitingPayment && (
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
Waiting for payment...
</span>
</div>
)}
{createQRCode && (
<div className='flex justify-center'>
<Image
src={createQRCode}
alt='QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<Textarea
value={createInvoice.bolt11}
readOnly
rows={4}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-center text-xs'>
Scan QR code or copy invoice. Payment will be detected
automatically.
</p>
</div>
)}
{createdApiKey && (
<div className='space-y-3 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-green-700 uppercase dark:text-green-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>API Key Created Successfully</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-green-300 text-xs hover:bg-green-100 dark:border-green-700 dark:hover:bg-green-900'
onClick={() => handleCopy(createdApiKey)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={createdApiKey}
readOnly
rows={2}
className='border-green-200 bg-white font-mono text-xs dark:border-green-800 dark:bg-green-950/50'
/>
<div className='flex items-center justify-between text-xs text-green-700 dark:text-green-300'>
<span>Your API key is ready to use!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-green-700 hover:text-green-800 dark:text-green-300 dark:hover:text-green-200'
onClick={() => setCreatedApiKey('')}
>
Dismiss
</Button>
</div>
</div>
)}
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>2 · Top up existing key</span>
{showTopupDetails && (
<span className='text-primary'>Ready to create invoice</span>
)}
</header>
<div className='space-y-2'>
<Input
value={topupApiKey}
onChange={(event) => setTopupApiKey(event.target.value)}
placeholder='Your API key (sk-...)'
className='font-mono text-sm'
onFocus={() => setHasInteractedTopup(true)}
/>
<Input
type='number'
value={topupAmount}
onChange={(event) => setTopupAmount(event.target.value)}
placeholder='Amount to add in sats'
className='text-sm'
onFocus={() => setHasInteractedTopup(true)}
/>
</div>
{showTopupDetails && (
<div className='space-y-3'>
<Button
onClick={handleTopupInvoice}
disabled={isTopupping || !!topupInvoice}
variant='outline'
className='gap-2'
>
{isTopupping ? 'Creating invoice...' : 'Create Topup Invoice'}
</Button>
{topupInvoice && (
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>Topup Invoice ({topupInvoice.amount_sats} sats)</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(topupInvoice.bolt11)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
{topupQRCode && (
<div className='flex justify-center'>
<Image
src={topupQRCode}
alt='QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<Textarea
value={topupInvoice.bolt11}
readOnly
rows={4}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-center text-xs'>
Scan QR code or copy invoice. Balance will be added
automatically.
</p>
{isWaitingTopupPayment && (
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
Waiting for payment...
</span>
</div>
)}
</div>
)}
{topupApiKeyResult && (
<div className='space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-blue-700 uppercase dark:text-blue-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>Topup Successful</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-blue-300 text-xs hover:bg-blue-100 dark:border-blue-700 dark:hover:bg-blue-900'
onClick={() => handleCopy(topupApiKeyResult)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={topupApiKeyResult}
readOnly
rows={2}
className='border-blue-200 bg-white font-mono text-xs dark:border-blue-800 dark:bg-blue-950/50'
/>
<div className='flex items-center justify-between text-xs text-blue-700 dark:text-blue-300'>
<span>Balance has been added to your API key!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-blue-700 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200'
onClick={() => setTopupApiKeyResult('')}
>
Dismiss
</Button>
</div>
</div>
)}
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>3 · Recover from invoice</span>
{showRecoverDetails && (
<span className='text-primary'>Invoice provided</span>
)}
</header>
<Textarea
value={recoverInvoice}
onChange={(event) => setRecoverInvoice(event.target.value)}
placeholder='Paste BOLT11 invoice to recover API key...'
rows={showRecoverDetails ? 3 : 1}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedRecover(true)}
/>
{showRecoverDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRecoverInvoice}
disabled={isRecovering}
variant='secondary'
className='gap-2'
>
{isRecovering ? 'Recovering...' : 'Recover API Key'}
</Button>
<span className='text-muted-foreground text-xs'>
Recovers API key from a paid Lightning invoice.
</span>
</div>
)}
{recoveredApiKey && (
<div className='space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-purple-700 uppercase dark:text-purple-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>API Key Recovered</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-purple-300 text-xs hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900'
onClick={() => handleCopy(recoveredApiKey)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={recoveredApiKey}
readOnly
rows={2}
className='border-purple-200 bg-white font-mono text-xs dark:border-purple-800 dark:bg-purple-950/50'
/>
<div className='flex items-center justify-between text-xs text-purple-700 dark:text-purple-300'>
<span>Your recovered API key is ready to use!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-purple-700 hover:text-purple-800 dark:text-purple-300 dark:hover:text-purple-200'
onClick={() => setRecoveredApiKey('')}
>
Dismiss
</Button>
</div>
</div>
)}
</section>
</CardContent>
</Card>
);
}
+23 -40
View File
@@ -41,11 +41,8 @@ export function RevenueByModelTable({
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className='text-muted-foreground text-sm'>
Total Revenue:{' '}
<span className='text-foreground font-mono font-medium'>
{formatAmount(totalRevenue)}
</span>
<p className="text-sm text-muted-foreground">
Total Revenue: <span className="font-mono font-medium text-foreground">{formatAmount(totalRevenue)}</span>
</p>
</CardHeader>
<CardContent>
@@ -53,62 +50,48 @@ export function RevenueByModelTable({
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className='text-right'>Requests</TableHead>
<TableHead className='text-right'>Successful</TableHead>
<TableHead className='text-right'>Failed</TableHead>
<TableHead className='text-right'>Revenue</TableHead>
<TableHead className='w-[100px]'>Share</TableHead>
<TableHead className='text-right'>Refunds</TableHead>
<TableHead className='text-right'>Net Revenue</TableHead>
<TableHead className='text-right'>Avg/Request</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Successful</TableHead>
<TableHead className="text-right">Failed</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="w-[100px]">Share</TableHead>
<TableHead className="text-right">Refunds</TableHead>
<TableHead className="text-right">Net Revenue</TableHead>
<TableHead className="text-right">Avg/Request</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className='text-muted-foreground text-center'
>
<TableCell colSpan={9} className="text-center text-muted-foreground">
No model data available
</TableCell>
</TableRow>
) : (
models.map((model) => {
const share =
totalRevenue > 0
? (model.revenue_sats / totalRevenue) * 100
: 0;
const share = totalRevenue > 0 ? (model.revenue_sats / totalRevenue) * 100 : 0;
return (
<TableRow key={model.model}>
<TableCell className='font-medium'>{model.model}</TableCell>
<TableCell className='text-right font-mono'>
{model.requests}
</TableCell>
<TableCell className='text-right font-mono text-green-600'>
{model.successful}
</TableCell>
<TableCell className='text-right font-mono text-red-600'>
{model.failed}
</TableCell>
<TableCell className='text-right font-mono'>
<TableCell className="font-medium">{model.model}</TableCell>
<TableCell className="text-right font-mono">{model.requests}</TableCell>
<TableCell className="text-right text-green-600 font-mono">{model.successful}</TableCell>
<TableCell className="text-right text-red-600 font-mono">{model.failed}</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(model.revenue_sats)}
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Progress value={share} className='h-2' />
<span className='text-muted-foreground w-8 text-right text-xs'>
{share.toFixed(0)}%
</span>
<div className="flex items-center gap-2">
<Progress value={share} className="h-2" />
<span className="text-xs text-muted-foreground w-8 text-right">{share.toFixed(0)}%</span>
</div>
</TableCell>
<TableCell className='text-right font-mono text-red-500'>
<TableCell className="text-right text-red-500 font-mono">
{formatAmount(model.refunds_sats)}
</TableCell>
<TableCell className='text-right font-mono font-semibold'>
<TableCell className="text-right font-semibold font-mono">
{formatAmount(model.net_revenue_sats)}
</TableCell>
<TableCell className='text-muted-foreground text-right font-mono'>
<TableCell className="text-right text-muted-foreground font-mono">
{formatAmount(model.avg_revenue_per_request)}
</TableCell>
</TableRow>
+2 -194
View File
@@ -18,7 +18,6 @@ import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
interface SettingsData {
name?: string;
@@ -29,32 +28,9 @@ interface SettingsData {
http_url?: string;
onion_url?: string;
cashu_mints?: string[];
relays?: string[];
[key: string]: unknown;
}
const HANDLED_KEYS = [
'name',
'description',
'http_url',
'onion_url',
'npub',
'nsec',
'cashu_mints',
'relays',
'admin_password',
'id',
'updated_at',
];
const IGNORED_KEYS = [
'upstream_base_url',
'upstream_api_key',
'upstream_provider_fee',
'exchange_fee',
'models_path',
];
interface PasswordData {
current_password: string;
new_password: string;
@@ -68,7 +44,6 @@ export function AdminSettings() {
const [error, setError] = useState<string>('');
const [showSecrets, setShowSecrets] = useState(false);
const [newMint, setNewMint] = useState('');
const [newRelay, setNewRelay] = useState('');
const [passwordData, setPasswordData] = useState<PasswordData>({
current_password: '',
new_password: '',
@@ -154,7 +129,7 @@ export function AdminSettings() {
}
};
const handleInputChange = (field: string, value: unknown) => {
const handleInputChange = (field: string, value: string | boolean) => {
setSettings((prev) => ({
...prev,
[field]: value,
@@ -178,23 +153,6 @@ export function AdminSettings() {
}));
};
const addRelay = () => {
if (newRelay.trim()) {
setSettings((prev) => ({
...prev,
relays: [...(prev.relays || []), newRelay.trim()],
}));
setNewRelay('');
}
};
const removeRelay = (index: number) => {
setSettings((prev) => ({
...prev,
relays: prev.relays?.filter((_, i) => i !== index) || [],
}));
};
const renderSecretField = (
field: string,
label: string,
@@ -204,7 +162,7 @@ export function AdminSettings() {
const displayValue = showSecrets ? value : value ? '••••••••' : '';
return (
<div key={field} className='space-y-2'>
<div className='space-y-2'>
<Label htmlFor={field}>{label}</Label>
<div className='flex gap-2'>
<Input
@@ -232,89 +190,6 @@ export function AdminSettings() {
);
};
const renderDynamicField = (key: string, value: unknown) => {
const label = key
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
if (typeof value === 'boolean') {
return (
<div
key={key}
className='flex items-center justify-between space-y-0 py-4'
>
<Label htmlFor={key}>{label}</Label>
<Switch
id={key}
checked={value}
onCheckedChange={(checked) => handleInputChange(key, checked)}
/>
</div>
);
}
if (typeof value === 'number') {
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type='number'
value={value}
onChange={(e) => {
const val = e.target.value === '' ? 0 : Number(e.target.value);
handleInputChange(key, val);
}}
/>
</div>
);
}
if (Array.isArray(value)) {
const strValue = value.join(', ');
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Textarea
id={key}
value={strValue}
onChange={(e) => {
const arr = e.target.value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
handleInputChange(key, arr);
}}
placeholder='Comma separated values'
rows={2}
/>
</div>
);
}
const isSecret =
key.includes('key') ||
key.includes('password') ||
key.includes('secret') ||
key.includes('nsec');
if (isSecret) {
return renderSecretField(key, label);
}
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
value={(value as string) || ''}
onChange={(e) => handleInputChange(key, e.target.value)}
/>
</div>
);
};
if (loading) {
return (
<div className='flex items-center justify-center py-8'>
@@ -462,73 +337,6 @@ export function AdminSettings() {
</CardContent>
</Card>
{/* Relays */}
<Card>
<CardHeader>
<CardTitle>Nostr Relays</CardTitle>
<CardDescription>
Configure Nostr relays for communication
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newRelay'>Add Relay URL</Label>
<div className='flex gap-2'>
<Input
id='newRelay'
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder='wss://relay.example.com'
/>
<Button onClick={addRelay} disabled={!newRelay.trim()}>
Add Relay
</Button>
</div>
</div>
{settings.relays && settings.relays.length > 0 && (
<div className='space-y-2'>
<Label>Configured Relays</Label>
<div className='space-y-2'>
{settings.relays.map((relay, index) => (
<div
key={index}
className='flex items-center gap-2 rounded border p-2'
>
<span className='flex-1 text-sm'>{relay}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeRelay(index)}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Other Settings */}
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
<CardDescription>
Configure additional node settings
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{Object.keys(settings)
.filter(
(key) =>
!HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
)
.map((key) => renderDynamicField(key, settings[key]))}
</CardContent>
</Card>
<Card className='mt-6'>
<CardFooter className='flex justify-between'>
<Button
+27 -55
View File
@@ -9,7 +9,6 @@ import {
Key,
Clock,
DollarSign,
Activity,
} from 'lucide-react';
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
import {
@@ -56,21 +55,17 @@ export function TemporaryBalances({
const calculateTotals = (balances: TemporaryBalance[]) => {
let totalBalance = 0;
let totalSpent = 0;
let totalRequests = 0;
balances.forEach((balance) => {
totalBalance += balance.balance || 0;
totalSpent += balance.total_spent || 0;
totalRequests += balance.total_requests || 0;
});
return { totalBalance, totalSpent, totalRequests };
return { totalBalance };
};
const totals = data
? calculateTotals(data)
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
: { totalBalance: 0 };
return (
<>
@@ -142,32 +137,6 @@ export function TemporaryBalances({
{formatBalance(totals.totalBalance)}
</div>
</div>
<div className='rounded-lg border border-green-200 bg-gradient-to-r from-green-50 to-emerald-50 p-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Activity className='h-5 w-5 text-green-600' />
<span className='text-sm font-medium text-green-800'>
Total Spent
</span>
</div>
</div>
<div className='mt-2 text-2xl font-bold text-green-900'>
{formatBalance(totals.totalSpent)}
</div>
</div>
<div className='rounded-lg border border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50 p-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Key className='h-5 w-5 text-purple-600' />
<span className='text-sm font-medium text-purple-800'>
Total Requests
</span>
</div>
</div>
<div className='mt-2 text-2xl font-bold text-purple-900'>
{totals.totalRequests.toLocaleString()}
</div>
</div>
</div>
{/* Table */}
@@ -176,9 +145,9 @@ export function TemporaryBalances({
<div className='bg-muted hidden grid-cols-6 gap-2 p-3 text-sm font-semibold md:grid'>
<div>Hashed Key</div>
<div className='text-right'>Balance</div>
<div className='text-right'>Total Spent</div>
<div className='text-right'>Total Requests</div>
<div>Refund Address</div>
<div>Refund Mint</div>
<div>Currency</div>
<div className='text-right'>Expiry Time</div>
</div>
@@ -199,22 +168,22 @@ export function TemporaryBalances({
<div className='text-right font-mono'>
{formatBalance(balance.balance)}
</div>
<div className='text-right font-mono'>
{formatBalance(balance.total_spent)}
</div>
<div className='text-right font-mono'>
{balance.total_requests.toLocaleString()}
</div>
<div className='max-w-32 truncate font-mono text-xs break-all'>
{balance.refund_address || '-'}
</div>
<div className='max-w-32 truncate font-mono text-xs break-all'>
{balance.refund_mint_url || '-'}
</div>
<div className='truncate font-mono text-xs'>
{balance.refund_currency || '-'}
</div>
<div className='text-right font-mono text-xs'>
{balance.key_expiry_time ? (
{balance.refund_expiration_time ? (
<div className='flex items-center justify-end gap-1'>
<Clock className='h-3 w-3' />
<span>
{new Date(
balance.key_expiry_time * 1000
balance.refund_expiration_time * 1000
).toLocaleDateString()}
</span>
</div>
@@ -246,34 +215,26 @@ export function TemporaryBalances({
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Spent
Currency
</div>
<div className='truncate font-mono text-sm'>
{formatBalance(balance.total_spent)}
{balance.refund_currency || '-'}
</div>
</div>
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Requests
</div>
<div className='truncate font-mono text-sm'>
{balance.total_requests.toLocaleString()}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Expires
</div>
<div className='font-mono text-xs'>
{balance.key_expiry_time ? (
{balance.refund_expiration_time ? (
<div className='flex items-center gap-1'>
<Clock className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>
{new Date(
balance.key_expiry_time * 1000
balance.refund_expiration_time * 1000
).toLocaleDateString()}
</span>
</div>
@@ -294,6 +255,17 @@ export function TemporaryBalances({
</div>
</div>
)}
{balance.refund_mint_url && (
<div className='space-y-1 pt-2'>
<div className='text-muted-foreground text-xs font-medium'>
Refund Mint
</div>
<div className='font-mono text-xs break-all'>
{balance.refund_mint_url}
</div>
</div>
)}
</div>
</div>
))
+1 -5
View File
@@ -90,11 +90,7 @@ export function UsageMetricsChart({
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
itemStyle={{ fontSize: '12px' }}
labelStyle={{
fontSize: '12px',
color: 'hsl(var(--muted-foreground))',
marginBottom: '8px',
}}
labelStyle={{ fontSize: '12px', color: 'hsl(var(--muted-foreground))', marginBottom: '8px' }}
/>
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '16px' }} />
{dataKeys.map((dataKey) => (
+5 -9
View File
@@ -114,19 +114,15 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
return (
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-4'>
{cards.map((card) => (
<Card key={card.title} className='hover:bg-muted/50 transition-colors'>
<Card key={card.title} className="hover:bg-muted/50 transition-colors">
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
{card.title}
</CardTitle>
<div className={`bg-secondary rounded-full p-2`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
<CardTitle className='text-sm font-medium text-muted-foreground'>{card.title}</CardTitle>
<div className={`p-2 rounded-full bg-secondary`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold tracking-tight'>
{card.value}
</div>
<div className='text-2xl font-bold tracking-tight'>{card.value}</div>
</CardContent>
</Card>
))}
-5
View File
@@ -64,11 +64,6 @@ class ApiClient {
data,
config
);
console.log(`POST response from ${endpoint}:`, {
status: response.status,
data: response.data,
headers: response.headers,
});
return response.data;
} catch (error) {
this.handleAuthError(error);
+5 -63
View File
@@ -7,9 +7,6 @@ export const ProviderTypeSchema = z.object({
default_base_url: z.string(),
fixed_base_url: z.boolean(),
platform_url: z.string().nullable(),
can_create_account: z.boolean(),
can_topup: z.boolean(),
can_show_balance: z.boolean(),
});
export const UpstreamProviderSchema = z.object({
@@ -67,9 +64,7 @@ export const AdminModelSchema = z.object({
pricing: AdminModelPricingSchema.or(z.record(z.any())),
per_request_limits: z.record(z.any()).nullable().optional(),
top_provider: z.record(z.any()).nullable().optional(),
upstream_provider_id: z.union([z.string(), z.number()]).nullable().optional(),
canonical_slug: z.string().nullable().optional(),
alias_ids: z.array(z.string()).nullable().optional(),
upstream_provider_id: z.number().nullable().optional(),
enabled: z.boolean().default(true),
});
@@ -816,9 +811,7 @@ export class AdminService {
if (search) params.append('search', search);
params.append('limit', limit.toString());
return await apiClient.get<LogResponse>(
`/admin/api/logs?${params.toString()}`
);
return await apiClient.get<LogResponse>(`/admin/api/logs?${params.toString()}`);
}
static async getLogDates(): Promise<{ dates: string[] }> {
@@ -863,66 +856,15 @@ export class AdminService {
`/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
);
}
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
message: string;
}> {
return await apiClient.post<{
ok: boolean;
account_data: Record<string, unknown>;
message: string;
}>('/admin/api/upstream-providers/create-account', {
provider_type: providerType,
});
}
static async initiateProviderTopup(
providerId: number,
amount: number
): Promise<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}> {
return await apiClient.post<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}>(`/admin/api/upstream-providers/${providerId}/topup`, {
amount: amount,
});
}
static async checkTopupStatus(
providerId: number,
invoiceId: string
): Promise<{ ok: boolean; paid: boolean }> {
return await apiClient.get<{
ok: boolean;
paid: boolean;
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
}
static async getProviderBalance(providerId: number): Promise<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;
}> {
return await apiClient.get<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;
}>(`/admin/api/upstream-providers/${providerId}/balance`);
}
}
export const TemporaryBalanceSchema = z.object({
hashed_key: z.string(),
balance: z.number(),
total_spent: z.number(),
total_requests: z.number(),
refund_address: z.string().nullable(),
key_expiry_time: z.number().nullable(),
refund_mint_url: z.string().nullable(),
refund_currency: z.string().nullable(),
refund_expiration_time: z.number().nullable(),
});
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
+1
View File
@@ -18,3 +18,4 @@ export const useCurrencyStore = create<CurrencyState>()(
}
)
);

Some files were not shown because too many files have changed in this diff Show More