Compare commits

..
Author SHA1 Message Date
Shroominic d8a81fb137 Optimize streaming usage extraction and Cashu refund buffering 2025-12-22 20:33:37 +01:00
Shroominic b18714e092 ruff format 2025-12-22 19:31:37 +01:00
Shroominic 4181adaa23 rename responses to responses_api 2025-12-22 19:30:58 +01:00
9qeklajc 42546fd387 refactor 2025-12-15 21:48:15 +01:00
9qeklajc cf28088afe clean up 2025-12-15 21:20:36 +01:00
9qeklajc 82d2627c60 added reponse api 2025-12-15 21:11:43 +01:00
shroominicandGitHub c11cc107c8 Merge pull request #248 from Routstr/feature/dynamic-settings
feat(ui): make admin settings dynamic based on backend response
2025-12-11 14:04:49 +08:00
shroominicandGitHub 19b5f2889a Merge pull request #249 from Routstr/refactor-remove-unused-functions
refactor: rm unused functions
2025-12-11 13:30:59 +08:00
Shroominic 52601f89bd ruff fix 2025-12-11 13:28:28 +08:00
shroominicandGitHub 72b281b815 Merge branch 'v0.2.1' into refactor-remove-unused-functions 2025-12-11 13:24:59 +08:00
shroominicandGitHub 87b1443c23 Merge pull request #253 from Routstr/lightning
create and topup token with lightning
2025-12-11 12:58:36 +08:00
Shroominic 29129f8953 prettier 2025-12-11 12:56:29 +08:00
Shroominic c97c74a2ee cleanup logs 2025-12-11 12:55:01 +08:00
Shroominic 1e37c42ea0 fix ui build 2025-12-11 12:51:27 +08:00
Shroominic 4c7887fa4e update algorithm logs 2025-12-11 11:04:00 +08:00
Cursor Agent 2cc5063dee Merge branch 'origin/v0.2.1' into lightning to resolve conflicts 2025-12-11 02:59:20 +00:00
shroominicandGitHub e4eda59e6a Merge pull request #256 from Routstr/improve-console-logging
Improve console logging
2025-12-11 10:54:05 +08:00
shroominicandGitHub 7f918eab6a Merge pull request #252 from Routstr/model-fetch-issue
open router free model breaks openrouter model fetching
2025-12-11 10:52:28 +08:00
Shroominic b8c34afef8 improve logging 2025-12-11 10:50:50 +08:00
shroominicandGitHub 2aee75e7d5 Merge pull request #255 from Routstr/rm-shaky
Rm shaky test
2025-12-11 10:12:33 +08:00
Shroominic 4d67af51ab rm shaky test 2025-12-11 10:08:45 +08:00
shroominicandGitHub 2f841dfcbc Merge pull request #254 from Routstr/format
make format required & fix ui formating
2025-12-11 10:01:38 +08:00
9qeklajc 7ca69063bd make format required & fix ui formating 2025-12-10 22:51:12 +01:00
9qeklajc 085ff75d1d create and topup token with lightning 2025-12-10 22:47:37 +01:00
Shroominic 6b4b3924a1 feat(ui): make admin settings dynamic based on backend response
This update changes the admin settings page to dynamically render settings fields based on the JSON response from the backend, rather than hardcoding them. This ensures that new settings added to the backend are automatically available in the UI without code changes.

- Specific handling for known fields like name, description, urls, keys, mints, and relays remains to provide a polished UX.
- All other fields are rendered dynamically based on their type (boolean, number, string, array).
- Sensitive fields (keys, passwords) are automatically masked.
- Specific internal/unused fields are ignored.
2025-12-02 15:23:00 +08:00
Shroominic a226a78222 rm unused functions 2025-12-02 12:40:54 +08:00
49 changed files with 12271 additions and 2138 deletions
+6 -2
View File
@@ -62,14 +62,18 @@ 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
@@ -0,0 +1,39 @@
"""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")
+2 -18
View File
@@ -150,18 +150,6 @@ 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, "provider_type", "unknown"
)
current_provider_name = getattr(current_provider, "provider_type", "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
@@ -294,12 +282,8 @@ def create_model_mappings(
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
logger.debug(
"Created model mappings",
extra={
"unique_model_count": len(unique_models),
"total_alias_count": len(model_instances),
"provider_distribution": provider_counts,
},
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
extra={"provider_distribution": provider_counts},
)
return model_instances, provider_map, unique_models
+3
View File
@@ -10,6 +10,7 @@ from .auth import validate_bearer_key
from .core.db import ApiKey, AsyncSession, 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
router = APIRouter()
@@ -238,6 +239,8 @@ 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)
+23 -3
View File
@@ -1,4 +1,5 @@
import os
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator
@@ -71,6 +72,28 @@ 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)
@@ -126,8 +149,6 @@ 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"
@@ -144,7 +165,6 @@ 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")
+10
View File
@@ -338,6 +338,11 @@ 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 [],
@@ -360,6 +365,11 @@ 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,
+3 -3
View File
@@ -54,9 +54,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, 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
@@ -104,6 +101,9 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
yield
except asyncio.CancelledError:
# Expected during shutdown
pass
except Exception as e:
logger.error(
"Application startup failed",
+3 -1
View File
@@ -62,7 +62,9 @@ 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']}")
logger.debug(
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
)
events.append(event)
elif data[0] == "EOSE" and data[1] == sub_id:
logger.debug("Received EOSE message")
+270
View File
@@ -0,0 +1,270 @@
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()
+1 -1
View File
@@ -215,7 +215,7 @@ async def query_nip91_events(
continue
events_out.append(ev_dict)
logger.debug(
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
)
if drained:
last_event_ts = time.time()
+13
View File
@@ -132,7 +132,20 @@ async def calculate_cost( # todo: can be sync
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)
-221
View File
@@ -1,8 +1,6 @@
import asyncio
import json
import random
from pathlib import Path
from urllib.request import urlopen
import httpx
from fastapi import APIRouter, Depends
@@ -89,41 +87,6 @@ def _has_valid_pricing(model: dict) -> bool:
return True
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Fetches model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
try:
with urlopen(f"{base_url}/models") as response:
data = json.loads(response.read().decode("utf-8"))
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 fetching models from OpenRouter API: {e}")
return []
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"
@@ -169,69 +132,6 @@ def is_openrouter_upstream() -> bool:
return base.lower() == "https://openrouter.ai/api/v1"
def load_models() -> list[Model]:
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
The file path can be specified via the ``MODELS_PATH`` environment variable.
If a user-provided models.json exists, it will be used. Otherwise, models are
automatically fetched from OpenRouter API in memory. If the example file exists
and no user file is provided, it will be used as a fallback.
"""
try:
models_path = Path(settings.models_path)
except Exception:
models_path = Path("models.json")
# Check if user has actively provided a models.json file
if models_path.exists():
logger.info(f"Loading models from user-provided file: {models_path}")
try:
with models_path.open("r") as f:
data = json.load(f)
return [Model(**model) for model in data.get("models", [])] # type: ignore
except Exception as e:
logger.error(f"Error loading models from {models_path}: {e}")
# Fall through to auto-generation
# Only auto-generate from OpenRouter when upstream is OpenRouter
if not is_openrouter_upstream():
logger.info(
"Skipping auto-generation from OpenRouter because upstream_base_url is not https://openrouter.ai/api/v1"
)
return []
logger.info("Auto-generating models from OpenRouter API")
try:
source_filter = settings.source or None
except Exception:
source_filter = None
source_filter = source_filter if source_filter and source_filter.strip() else None
models_data = fetch_openrouter_models(source_filter=source_filter)
if not models_data:
logger.error("Failed to fetch models from OpenRouter API")
return []
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
valid_models = []
for model_data in models_data:
try:
model = Model(**model_data) # type: ignore
valid_models.append(model)
except Exception as e:
model_id = model_data.get("id", "unknown")
logger.warning(f"Skipping model {model_id} - validation failed: {e}")
if len(valid_models) != len(models_data):
logger.warning(
f"Filtered out {len(models_data) - len(valid_models)} models with incomplete data"
)
return valid_models
def _row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
@@ -460,57 +360,6 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
return model
async def ensure_models_bootstrapped() -> None:
async with create_session() as s:
existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore
if existing:
return
try:
models_path = Path(settings.models_path)
except Exception:
models_path = Path("models.json")
models_to_insert: list[dict] = []
if models_path.exists():
try:
with models_path.open("r") as f:
data = json.load(f)
models_to_insert = data.get("models", [])
logger.info(
f"Bootstrapping {len(models_to_insert)} models from {models_path}"
)
except Exception as e:
logger.error(f"Error loading models from {models_path}: {e}")
if not models_to_insert and is_openrouter_upstream():
logger.info("Bootstrapping models from OpenRouter API")
source_filter = None
try:
src = settings.source or None
source_filter = src if src and src.strip() else None
except Exception:
pass
models_to_insert = fetch_openrouter_models(source_filter=source_filter)
elif not models_to_insert:
logger.info(
"No models.json found and upstream is not OpenRouter; skipping bootstrap"
)
for m in models_to_insert:
try:
model = Model(**m) # type: ignore
except Exception:
# Some OpenRouter models include extra fields; only map required ones
continue
exists = await s.get(ModelRow, model.id)
if exists:
continue
payload = _model_to_row_payload(model)
s.add(ModelRow(**payload)) # type: ignore
await s.commit()
async def _update_sats_pricing_once() -> None:
"""Update sats pricing once for all provider models (in-memory only)."""
from ..proxy import get_upstreams
@@ -672,76 +521,6 @@ def _pricing_matches(
return True
async def refresh_models_periodically() -> None:
"""Background task: periodically fetch OpenRouter models and insert new ones.
- Respects optional SOURCE filter from settings
- Does not overwrite existing rows
- Sleeps according to settings.models_refresh_interval_seconds; disabled when 0
"""
interval = getattr(settings, "models_refresh_interval_seconds", 0)
if not interval or interval <= 0:
return
# Only refresh from OpenRouter when upstream is OpenRouter
if not is_openrouter_upstream():
logger.info("Skipping models refresh: upstream_base_url is not OpenRouter")
return
while True:
try:
try:
if not settings.enable_models_refresh:
return
except Exception:
pass
try:
src = settings.source or None
source_filter = src if src and src.strip() else None
except Exception:
source_filter = None
models = fetch_openrouter_models(source_filter=source_filter)
if not models:
await asyncio.sleep(interval)
continue
async with create_session() as s:
result = await s.exec(select(ModelRow.id)) # type: ignore
existing_ids = {
row[0] if isinstance(row, tuple) else row for row in result.all()
}
inserted = 0
for m in models:
try:
model = Model(**m) # type: ignore
except Exception:
continue
if model.id in existing_ids:
continue
payload = _model_to_row_payload(model)
try:
s.add(ModelRow(**payload)) # type: ignore
except Exception:
pass
inserted += 1
if inserted:
await s.commit()
logger.info(f"Inserted {inserted} new models from OpenRouter")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(
"Error during models refresh",
extra={"error": str(e), "error_type": type(e).__name__},
)
try:
jitter = max(0.0, float(interval) * 0.1)
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
@models_router.get("/v1/models")
@models_router.get("/models", include_in_schema=False)
async def models(session: AsyncSession = Depends(get_session)) -> dict:
-4
View File
@@ -110,10 +110,6 @@ 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:
+53 -28
View File
@@ -141,20 +141,14 @@ async def proxy(
"unauthorized", "Unauthorized", 401, request=request
)
logger.info( # TODO: move to middleware, async
"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],
},
)
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)
model_id = request_body_dict.get("model", "unknown")
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_obj = get_model_instance(model_id)
if not model_obj:
@@ -180,9 +174,14 @@ async def proxy(
check_token_balance(headers, request_body_dict, max_cost_for_model)
if x_cashu := headers.get("x-cashu", None):
return await upstream.handle_x_cashu(
request, x_cashu, path, max_cost_for_model, model_obj
)
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
)
elif auth := headers.get("authorization", None):
key = await get_bearer_token_key(headers, path, session, auth)
@@ -197,28 +196,36 @@ async def proxy(
)
logger.debug("Processing unauthenticated GET request", extra={"path": path})
# TODO: why is this needed? can we remove it?
headers = upstream.prepare_headers(dict(request.headers))
return await upstream.forward_get_request(request, path, headers)
# Only pay for request if we have request body data (for completions endpoints)
if request_body_dict:
await pay_for_request(key, max_cost_for_model, session)
# Prepare headers for upstream
headers = upstream.prepare_headers(dict(request.headers))
# Forward to upstream and handle response
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
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,
)
if response.status_code != 200:
await revert_pay_for_request(key, session, max_cost_for_model)
@@ -321,6 +328,24 @@ async def get_bearer_token_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]:
request_body_dict = {}
if request_body:
+475 -1197
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,566 @@
"""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."""
# For X-Cashu requests, we force non-streaming response even if upstream is streaming,
# because we need to calculate the refund and include it in the headers.
# Refund token must be in the headers, which are sent before the body.
content = await response.aread()
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
# Check if it was streaming content to process usage correctly
is_streaming_content = ChatCompletionProcessor.is_streaming_response(
content_str
)
if is_streaming_content:
# Buffer streaming content to calculate refund before sending headers.
# Headers (containing refund token) must be sent before body, preventing
# true streaming while supporting dynamic refunds.
# Extract usage data from buffered content
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
)
# Refund token will be added to new response headers below
except Exception as e:
logger.error(
"Error calculating refund for buffered streaming response",
extra={"error": str(e)},
)
# We return a StreamingResponse that yields the buffered content
# This satisfies the client expecting a stream, while allowing us to set headers.
response_headers = ChatCompletionProcessor.clean_response_headers(
dict(response.headers)
)
if "refund_token" in locals() and refund_token:
response_headers["X-Cashu"] = refund_token
lines = content_str.strip().split("\n")
return StreamingResponse(
ChatCompletionProcessor.create_streaming_generator(lines),
status_code=response.status_code,
headers=response_headers,
media_type="text/event-stream", # Keep original media type if possible, or text/event-stream
)
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_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."""
# Force non-streaming for X-Cashu to handle refunds in headers
content = await response.aread()
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
is_streaming_content = ResponsesApiProcessor.is_streaming_response(content_str)
if is_streaming_content:
usage_data, model = (
ResponsesApiProcessor.extract_usage_with_reasoning_tokens(content_str)
)
refund_token = None
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
)
except Exception as e:
logger.error(
"Error calculating refund for buffered Responses API response",
extra={"error": str(e)},
)
response_headers = ResponsesApiProcessor.clean_response_headers(
dict(response.headers)
)
if refund_token:
response_headers["X-Cashu"] = refund_token
lines = content_str.strip().split("\n")
return StreamingResponse(
ResponsesApiProcessor.create_streaming_generator(lines),
status_code=response.status_code,
headers=response_headers,
media_type="text/event-stream",
)
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_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 self.send_refund(
emergency_refund, unit=unit, mint=mint
)
response_headers["X-Cashu"] = refund_token
logger.warning(
"Emergency refund issued due to JSON parse error in Responses API",
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,
)
+1 -1
View File
@@ -193,7 +193,7 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.info(
logger.debug(
f"Initialized {provider_row.provider_type} provider",
extra={
"base_url": provider_row.base_url,
+111
View File
@@ -0,0 +1,111 @@
"""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,
)
-18
View File
@@ -102,11 +102,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
logger.debug(
"Fetching models from PPQ.AI",
extra={"url": url, "has_api_key": bool(self.api_key)},
)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
@@ -114,10 +109,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
data = response.json()
models_data = data.get("data", [])
logger.info(
"Fetched models from PPQ.AI",
extra={"model_count": len(models_data)},
)
or_models = [
Model(**model) # type: ignore
@@ -198,15 +189,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
return models
except httpx.HTTPStatusError as e:
logger.error(
"HTTP error fetching models from PPQ.AI",
extra={
"status_code": e.response.status_code,
"error": str(e),
},
)
return []
except Exception as e:
logger.error(
"Error fetching models from PPQ.AI",
+200
View File
@@ -0,0 +1,200 @@
"""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,
)
@@ -0,0 +1,170 @@
"""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
@@ -142,46 +142,6 @@ 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 ApiKey
# Create test data
for i in range(100):
key = ApiKey(
hashed_key=f"test_key_{i}",
balance=1000000,
total_spent=0,
total_requests=0,
)
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(ApiKey).where(ApiKey.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
+1 -3
View File
@@ -29,9 +29,7 @@ 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='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'>
<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'>
<CardHeader className='space-y-1'>
<CardTitle className='text-center text-2xl font-bold'>
Admin Login
+10 -3
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 whitespace-pre break-all'>
<pre className='font-mono text-sm break-all whitespace-pre'>
{log.message}
</pre>
</div>
@@ -116,7 +116,12 @@ 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 ? (
@@ -175,7 +180,9 @@ 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' ? (
+23 -10
View File
@@ -205,32 +205,45 @@ export default function ModelsPage() {
</div>
</div>
{totalModels === 0 && (
<Alert className="mb-4">
<Alert className='mb-4'>
<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='text-sm space-y-1'>
<p className='font-medium'>Common issues:</p>
<ul className='list-disc list-inside space-y-1 ml-2'>
<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'>
<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='mt-2 text-xs text-muted-foreground'>
Current endpoint: <code className='bg-muted px-1 rounded'>{groupData.group_url}</code>
<p className='text-muted-foreground mt-2 text-xs'>
Current endpoint:{' '}
<code className='bg-muted rounded px-1'>
{groupData.group_url}
</code>
</p>
)}
</div>
+49 -23
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,8 +131,13 @@ 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='text-3xl font-bold tracking-tight mb-6'>Dashboard</h1>
<DashboardBalanceSummary displayUnit={displayUnit} usdPerSat={usdPerSat} />
<h1 className='mb-6 text-3xl font-bold tracking-tight'>
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'>
@@ -140,8 +145,9 @@ export default function DashboardPage() {
<h2 className='text-xl font-semibold tracking-tight'>
Usage Analytics
</h2>
<p className='text-muted-foreground text-sm mt-1'>
Monitor requests, errors, and revenue over the last {timeRange} hours
<p className='text-muted-foreground mt-1 text-sm'>
Monitor requests, errors, and revenue over the last {timeRange}{' '}
hours
</p>
</div>
<div className='flex items-center gap-4'>
@@ -176,27 +182,31 @@ export default function DashboardPage() {
<div className='space-y-6'>
{summaryLoading ? (
<div className='text-center py-8'>Loading summary...</div>
<div className='py-8 text-center'>Loading summary...</div>
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<div className='grid gap-6 lg:grid-cols-2'>
{metricsLoading ? (
<div className='text-center py-8 col-span-2'>
<div className='col-span-2 py-8 text-center'>
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={[
{
@@ -218,7 +228,11 @@ 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={[
{
@@ -239,7 +253,11 @@ 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={[
{
@@ -260,7 +278,11 @@ 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={[
{
@@ -270,7 +292,7 @@ export default function DashboardPage() {
},
]}
/>
<div className="space-y-6">
<div className='space-y-6'>
{summaryData && summaryData.unique_models.length > 0 && (
<Card>
<CardHeader>
@@ -306,7 +328,9 @@ 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>
@@ -335,16 +359,18 @@ export default function DashboardPage() {
</div>
{revenueByModelLoading ? (
<div className='text-center py-8'>Loading revenue by model...</div>
<div className='py-8 text-center'>
Loading revenue by model...
</div>
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
<RevenueByModelTable
<RevenueByModelTable
models={revenueByModelData.models}
totalRevenue={revenueByModelData.total_revenue_sats}
/>
) : null}
{errorLoading ? (
<div className='text-center py-8'>Loading errors...</div>
<div className='py-8 text-center'>Loading errors...</div>
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}
+73 -34
View File
@@ -76,7 +76,11 @@ function ProviderBalance({
);
const queryClient = useQueryClient();
const { data: balanceData, isLoading, error } = useQuery({
const {
data: balanceData,
isLoading,
error,
} = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
@@ -108,7 +112,10 @@ function ProviderBalance({
mutationFn: async (amount: number) => {
console.log('Calling top-up API with:', { providerId, amount });
try {
const result = await AdminService.initiateProviderTopup(providerId, amount);
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
console.log('API returned:', result);
return result;
} catch (err) {
@@ -120,11 +127,8 @@ function ProviderBalance({
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
) {
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,
@@ -172,19 +176,23 @@ function ProviderBalance({
// 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;
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
@@ -266,9 +274,7 @@ function ProviderBalance({
<path d='M5 13l4 4L19 7'></path>
</svg>
</div>
<p className='text-center font-semibold'>
Top-up successful!
</p>
<p className='text-center font-semibold'>Top-up successful!</p>
</div>
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'>
@@ -482,7 +488,9 @@ export default function ProvidersPage() {
...formData,
api_key: String(response.account_data.api_key),
});
toast.success('Account created successfully! API key has been filled in.');
toast.success(
'Account created successfully! API key has been filled in.'
);
} else {
toast.success('Account created, but no API key returned.');
}
@@ -778,8 +786,7 @@ export default function ProvidersPage() {
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card
fees, etc.
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
@@ -856,9 +863,11 @@ export default function ProvidersPage() {
<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)}
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
)}
/>
)}
<Button
@@ -925,9 +934,16 @@ export default function ProvidersPage() {
{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.
No models configured. Add custom models
to use this provider.
</div>
<Button variant='outline' size='sm' onClick={() => handleAddModel(provider.id)}>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
@@ -970,7 +986,12 @@ export default function ProvidersPage() {
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleEditModel(provider.id, model)}
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
@@ -1027,10 +1048,17 @@ export default function ProvidersPage() {
<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.
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<Button variant='outline' size='sm' onClick={() => handleAddModel(provider.id)}>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
@@ -1079,7 +1107,12 @@ export default function ProvidersPage() {
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleEditModel(provider.id, model)}
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
@@ -1126,7 +1159,12 @@ export default function ProvidersPage() {
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => handleOverrideModel(provider.id, model)}
onClick={() =>
handleOverrideModel(
provider.id,
model
)
}
>
<Plus className='mr-1 h-3 w-3' />
Override
@@ -1245,7 +1283,7 @@ export default function ProvidersPage() {
</div>
)}
<div className='flex items-center space-x-2'>
<Switch
<Switch
id='edit_enabled'
checked={formData.enabled}
onCheckedChange={(checked) =>
@@ -1277,8 +1315,7 @@ export default function ProvidersPage() {
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card
fees, etc.
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
@@ -1303,7 +1340,9 @@ export default function ProvidersPage() {
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() => setModelDialogState((prev) => ({ ...prev, isOpen: false }))}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-models', modelDialogState.providerId],
+76 -41
View File
@@ -102,7 +102,8 @@ export function AddProviderModelDialog({
}: AddProviderModelDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPresetOpen, setIsPresetOpen] = useState(false);
const [selectedPresetLabel, setSelectedPresetLabel] = useState('Select a preset');
const [selectedPresetLabel, setSelectedPresetLabel] =
useState('Select a preset');
const form = useForm<FormData>({
resolver: zodResolver(FormSchema) as never,
@@ -149,7 +150,10 @@ export function AddProviderModelDialog({
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;
const topProvider = initialData.top_provider as Record<
string,
unknown
> | null;
form.reset({
id: initialData.id,
@@ -250,7 +254,9 @@ export function AddProviderModelDialog({
form.setValue('context_length', model.context_length);
form.setValue(
'modality',
typeof architecture?.modality === 'string' ? architecture.modality : 'text'
typeof architecture?.modality === 'string'
? architecture.modality
: 'text'
);
form.setValue(
'input_modalities_raw',
@@ -318,7 +324,10 @@ export function AddProviderModelDialog({
setIsSubmitting(true);
try {
let perRequestLimits: Record<string, unknown> | null = null;
if (data.per_request_limits_raw && data.per_request_limits_raw.trim().length) {
if (
data.per_request_limits_raw &&
data.per_request_limits_raw.trim().length
) {
try {
perRequestLimits = JSON.parse(data.per_request_limits_raw);
} catch {
@@ -336,7 +345,9 @@ export function AddProviderModelDialog({
context_length: data.context_length,
architecture: {
modality: data.modality,
input_modalities: listFromString(data.input_modalities_raw || data.modality),
input_modalities: listFromString(
data.input_modalities_raw || data.modality
),
output_modalities: listFromString(
data.output_modalities_raw || data.modality
),
@@ -366,10 +377,9 @@ export function AddProviderModelDialog({
is_moderated: data.top_provider_is_moderated,
}
: null,
upstream_provider_id:
data.upstream_provider_id?.trim().length
? data.upstream_provider_id.trim()
: providerId,
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,
@@ -416,7 +426,7 @@ export function AddProviderModelDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
<div className='rounded-md border bg-muted/30 p-3'>
<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}>
@@ -425,23 +435,25 @@ export function AddProviderModelDialog({
variant='outline'
role='combobox'
aria-expanded={isPresetOpen}
className='w-full justify-between text-left text-sm overflow-hidden'
className='w-full justify-between overflow-hidden text-left text-sm'
>
<span className='truncate'>
{isLoadingPresets ? 'Loading presets...' : selectedPresetLabel}
{isLoadingPresets
? 'Loading presets...'
: selectedPresetLabel}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className='w-80 max-w-sm p-0'
align='start'
sideOffset={4}
<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
<CommandList
className='max-h-64 overflow-y-auto overscroll-contain'
onWheel={(e) => e.stopPropagation()}
>
@@ -475,8 +487,9 @@ export function AddProviderModelDialog({
</PopoverContent>
</Popover>
</div>
<div className='text-muted-foreground text-xs mt-1'>
Prefill fields from a preset model definition, then adjust as needed.
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
</div>
</div>
)}
@@ -496,7 +509,9 @@ export function AddProviderModelDialog({
disabled={isOverride || isEdit}
/>
</FormControl>
<FormDescription>Unique identifier for the model</FormDescription>
<FormDescription>
Unique identifier for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -524,7 +539,11 @@ export function AddProviderModelDialog({
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder='Brief description...' {...field} rows={2} />
<Textarea
placeholder='Brief description...'
{...field}
rows={2}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -575,10 +594,7 @@ export function AddProviderModelDialog({
<FormItem>
<FormLabel>Input Modalities</FormLabel>
<FormControl>
<Input
placeholder='text, image, file'
{...field}
/>
<Input placeholder='text, image, file' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
@@ -593,10 +609,7 @@ export function AddProviderModelDialog({
<FormItem>
<FormLabel>Output Modalities</FormLabel>
<FormControl>
<Input
placeholder='text, image'
{...field}
/>
<Input placeholder='text, image' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
@@ -643,7 +656,10 @@ export function AddProviderModelDialog({
<FormItem>
<FormLabel>Canonical Slug</FormLabel>
<FormControl>
<Input placeholder='google/gemini-3-pro-preview-20251117' {...field} />
<Input
placeholder='google/gemini-3-pro-preview-20251117'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -697,15 +713,19 @@ export function AddProviderModelDialog({
rows={4}
/>
</FormControl>
<FormDescription>JSON object; leave empty for none</FormDescription>
<FormDescription>
JSON object; leave empty for none
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='space-y-4 rounded-md border bg-muted/20 p-4'>
<h4 className='text-sm font-medium'>Pricing (USD per 1M tokens)</h4>
<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}
@@ -827,7 +847,7 @@ export function AddProviderModelDialog({
</div>
</div>
<div className='space-y-4 rounded-md border bg-muted/20 p-4'>
<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
@@ -870,11 +890,18 @@ export function AddProviderModelDialog({
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>
<FormLabel className='text-base'>
Is Moderated
</FormLabel>
<FormDescription>
Whether provider enforces moderation
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@@ -892,7 +919,10 @@ export function AddProviderModelDialog({
<FormDescription>Enable this model for use</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@@ -908,8 +938,14 @@ export function AddProviderModelDialog({
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'}
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{isEdit
? 'Save Changes'
: isOverride
? 'Create Override'
: 'Create Model'}
</Button>
</DialogFooter>
</form>
@@ -918,4 +954,3 @@ export function AddProviderModelDialog({
</Dialog>
);
}
+13 -10
View File
@@ -2,11 +2,12 @@
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 {
type Model,
type GroupSettings,
} from '@/lib/api/schemas/models';
import { AdminService, type AdminModelGroup, type AdminModel } from '@/lib/api/services/admin';
AdminService,
type AdminModelGroup,
type AdminModel,
} from '@/lib/api/services/admin';
type ModelGroup = AdminModelGroup;
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { EditGroupForm } from '@/components/EditGroupForm';
@@ -514,7 +515,7 @@ export function ModelSelector({
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
@@ -557,7 +558,7 @@ export function ModelSelector({
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
@@ -901,10 +902,10 @@ export function ModelSelector({
)}
{/* Model Management Actions */}
{groupData && (
<Button
onClick={() => handleAddModelClick(parseInt(groupData.id))}
<Button
onClick={() => handleAddModelClick(parseInt(groupData.id))}
className='gap-2'
variant="outline"
variant='outline'
>
<CheckSquare className='h-4 w-4' />
Add Custom Model
@@ -1306,7 +1307,9 @@ export function ModelSelector({
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() => setModelDialogState((prev) => ({ ...prev, isOpen: false }))}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onSuccess={handleModelUpdate}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
+12 -7
View File
@@ -48,20 +48,26 @@ export function CurrencyToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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
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>
</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}
>
@@ -71,4 +77,3 @@ export function CurrencyToggle() {
</DropdownMenu>
);
}
@@ -96,4 +96,3 @@ 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 text-center py-8'>
<p className='text-muted-foreground py-8 text-center'>
No errors found in the selected time period
</p>
</CardContent>
+284
View File
@@ -0,0 +1,284 @@
'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>
);
}
@@ -0,0 +1,452 @@
'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>
);
}
+105 -422
View File
@@ -2,22 +2,18 @@
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Bolt,
Copy,
KeyRound,
RefreshCcw,
ShieldCheck,
Terminal,
} from 'lucide-react';
import { Bolt, Copy, 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 { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
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;
@@ -62,36 +58,6 @@ 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) {
@@ -100,30 +66,15 @@ 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 [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 [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(
null
);
useEffect(() => {
if (!baseUrl && typeof window !== 'undefined') {
@@ -136,8 +87,6 @@ export function CheatSheet(): JSX.Element {
[baseUrl]
);
const activeApiKey = apiKeyInput.trim();
const {
data: nodeInfo,
isLoading: isInfoLoading,
@@ -168,141 +117,28 @@ export function CheatSheet(): JSX.Element {
}
}, []);
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);
const handleApiKeyCreated = useCallback(
(apiKey: string, snapshot: WalletSnapshot) => {
setApiKeyInput(apiKey);
setWalletInfo(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, normalizedBaseUrl]);
setRefundReceipt(null);
},
[]
);
const handleSyncBalance = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
const handleApiKeyChanged = useCallback((apiKey: string) => {
setApiKeyInput(apiKey);
}, []);
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 handleWalletInfoUpdated = useCallback((info: WalletSnapshot | null) => {
setWalletInfo(info);
}, []);
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 handleRefundComplete = useCallback((receipt: RefundReceipt) => {
setRefundReceipt(receipt);
setWalletInfo(null);
setApiKeyInput('');
}, []);
const handleRefreshInfo = useCallback(async (): Promise<void> => {
const result = await refetchNodeInfo();
@@ -314,7 +150,7 @@ export function CheatSheet(): JSX.Element {
}, [refetchNodeInfo]);
const curlSnippet = useMemo(() => {
const keyPreview = activeApiKey || 'YOUR_API_KEY';
const keyPreview = apiKeyInput || 'YOUR_API_KEY';
return [
`curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`,
` -H "Authorization: Bearer ${keyPreview}"`,
@@ -327,27 +163,21 @@ export function CheatSheet(): JSX.Element {
' ]',
" }'",
].join('\n');
}, [activeApiKey, normalizedBaseUrl]);
}, [apiKeyInput, 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='min-h-screen bg-gradient-to-b from-background via-background to-muted'>
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
<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 right-0 top-0 hidden md:block'>
<div className='absolute top-0 right-0 hidden md:block'>
<Button asChild size='sm' className='px-3 text-xs'>
<a href='/login'>Admin</a>
</Button>
</div>
<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' />
<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' />
Routstr cheat sheet
</div>
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
@@ -365,10 +195,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='h-4 w-4 text-primary' />
<ShieldCheck className='text-primary h-4 w-4' />
Node identity
</CardTitle>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
/v1/info snapshot
</p>
</div>
@@ -404,7 +234,7 @@ export function CheatSheet(): JSX.Element {
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
Version
</dt>
<dd className='text-base font-medium'>
@@ -412,7 +242,7 @@ export function CheatSheet(): JSX.Element {
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
@@ -421,7 +251,7 @@ export function CheatSheet(): JSX.Element {
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
@@ -431,10 +261,10 @@ export function CheatSheet(): JSX.Element {
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
npub
</dt>
<dd className='flex items-center gap-2 break-all text-sm font-mono'>
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
{nodeInfo.npub}
<Button
variant='ghost'
@@ -449,7 +279,7 @@ export function CheatSheet(): JSX.Element {
)}
</dl>
<div className='space-y-2'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
@@ -478,10 +308,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='h-4 w-4 text-primary' />
<Terminal className='text-primary h-4 w-4' />
Quick docs
</CardTitle>
<span className='text-xs uppercase tracking-wide text-muted-foreground'>
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
curl-ready
</span>
</CardHeader>
@@ -502,8 +332,10 @@ export function CheatSheet(): JSX.Element {
<Copy className='h-4 w-4' />
</Button>
</div>
<div className='rounded-lg bg-muted p-4 font-mono text-sm leading-6'>
<pre className='whitespace-pre-wrap break-all'>{curlSnippet}</pre>
<div className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
<pre className='break-all whitespace-pre-wrap'>
{curlSnippet}
</pre>
</div>
<div className='flex gap-2'>
<Button
@@ -529,218 +361,69 @@ export function CheatSheet(): JSX.Element {
</Card>
</section>
<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>
<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>
<Separator />
<TabsContent value='cashu' className='space-y-4'>
<CashuPaymentWorkflow
baseUrl={normalizedBaseUrl}
onApiKeyCreated={handleApiKeyCreated}
/>
</TabsContent>
<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>
)}
<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'
/>
</div>
<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>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</main>
</div>
);
}
@@ -0,0 +1,685 @@
'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>
);
}
+40 -23
View File
@@ -41,8 +41,11 @@ export function RevenueByModelTable({
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className="text-sm text-muted-foreground">
Total Revenue: <span className="font-mono font-medium text-foreground">{formatAmount(totalRevenue)}</span>
<p className='text-muted-foreground text-sm'>
Total Revenue:{' '}
<span className='text-foreground font-mono font-medium'>
{formatAmount(totalRevenue)}
</span>
</p>
</CardHeader>
<CardContent>
@@ -50,48 +53,62 @@ 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-center text-muted-foreground">
<TableCell
colSpan={9}
className='text-muted-foreground text-center'
>
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 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">
<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'>
{formatAmount(model.revenue_sats)}
</TableCell>
<TableCell>
<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 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>
</TableCell>
<TableCell className="text-right text-red-500 font-mono">
<TableCell className='text-right font-mono text-red-500'>
{formatAmount(model.refunds_sats)}
</TableCell>
<TableCell className="text-right font-semibold font-mono">
<TableCell className='text-right font-mono font-semibold'>
{formatAmount(model.net_revenue_sats)}
</TableCell>
<TableCell className="text-right text-muted-foreground font-mono">
<TableCell className='text-muted-foreground text-right font-mono'>
{formatAmount(model.avg_revenue_per_request)}
</TableCell>
</TableRow>
+194 -2
View File
@@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
interface SettingsData {
name?: string;
@@ -28,9 +29,32 @@ interface SettingsData {
http_url?: string;
onion_url?: string;
cashu_mints?: string[];
relays?: string[];
[key: string]: unknown;
}
const HANDLED_KEYS = [
'name',
'description',
'http_url',
'onion_url',
'npub',
'nsec',
'cashu_mints',
'relays',
'admin_password',
'id',
'updated_at',
];
const IGNORED_KEYS = [
'upstream_base_url',
'upstream_api_key',
'upstream_provider_fee',
'exchange_fee',
'models_path',
];
interface PasswordData {
current_password: string;
new_password: string;
@@ -44,6 +68,7 @@ export function AdminSettings() {
const [error, setError] = useState<string>('');
const [showSecrets, setShowSecrets] = useState(false);
const [newMint, setNewMint] = useState('');
const [newRelay, setNewRelay] = useState('');
const [passwordData, setPasswordData] = useState<PasswordData>({
current_password: '',
new_password: '',
@@ -129,7 +154,7 @@ export function AdminSettings() {
}
};
const handleInputChange = (field: string, value: string | boolean) => {
const handleInputChange = (field: string, value: unknown) => {
setSettings((prev) => ({
...prev,
[field]: value,
@@ -153,6 +178,23 @@ export function AdminSettings() {
}));
};
const addRelay = () => {
if (newRelay.trim()) {
setSettings((prev) => ({
...prev,
relays: [...(prev.relays || []), newRelay.trim()],
}));
setNewRelay('');
}
};
const removeRelay = (index: number) => {
setSettings((prev) => ({
...prev,
relays: prev.relays?.filter((_, i) => i !== index) || [],
}));
};
const renderSecretField = (
field: string,
label: string,
@@ -162,7 +204,7 @@ export function AdminSettings() {
const displayValue = showSecrets ? value : value ? '••••••••' : '';
return (
<div className='space-y-2'>
<div key={field} className='space-y-2'>
<Label htmlFor={field}>{label}</Label>
<div className='flex gap-2'>
<Input
@@ -190,6 +232,89 @@ export function AdminSettings() {
);
};
const renderDynamicField = (key: string, value: unknown) => {
const label = key
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
if (typeof value === 'boolean') {
return (
<div
key={key}
className='flex items-center justify-between space-y-0 py-4'
>
<Label htmlFor={key}>{label}</Label>
<Switch
id={key}
checked={value}
onCheckedChange={(checked) => handleInputChange(key, checked)}
/>
</div>
);
}
if (typeof value === 'number') {
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type='number'
value={value}
onChange={(e) => {
const val = e.target.value === '' ? 0 : Number(e.target.value);
handleInputChange(key, val);
}}
/>
</div>
);
}
if (Array.isArray(value)) {
const strValue = value.join(', ');
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Textarea
id={key}
value={strValue}
onChange={(e) => {
const arr = e.target.value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
handleInputChange(key, arr);
}}
placeholder='Comma separated values'
rows={2}
/>
</div>
);
}
const isSecret =
key.includes('key') ||
key.includes('password') ||
key.includes('secret') ||
key.includes('nsec');
if (isSecret) {
return renderSecretField(key, label);
}
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
value={(value as string) || ''}
onChange={(e) => handleInputChange(key, e.target.value)}
/>
</div>
);
};
if (loading) {
return (
<div className='flex items-center justify-center py-8'>
@@ -337,6 +462,73 @@ export function AdminSettings() {
</CardContent>
</Card>
{/* Relays */}
<Card>
<CardHeader>
<CardTitle>Nostr Relays</CardTitle>
<CardDescription>
Configure Nostr relays for communication
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newRelay'>Add Relay URL</Label>
<div className='flex gap-2'>
<Input
id='newRelay'
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder='wss://relay.example.com'
/>
<Button onClick={addRelay} disabled={!newRelay.trim()}>
Add Relay
</Button>
</div>
</div>
{settings.relays && settings.relays.length > 0 && (
<div className='space-y-2'>
<Label>Configured Relays</Label>
<div className='space-y-2'>
{settings.relays.map((relay, index) => (
<div
key={index}
className='flex items-center gap-2 rounded border p-2'
>
<span className='flex-1 text-sm'>{relay}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeRelay(index)}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Other Settings */}
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
<CardDescription>
Configure additional node settings
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{Object.keys(settings)
.filter(
(key) =>
!HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
)
.map((key) => renderDynamicField(key, settings[key]))}
</CardContent>
</Card>
<Card className='mt-6'>
<CardFooter className='flex justify-between'>
<Button
+5 -1
View File
@@ -90,7 +90,11 @@ 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) => (
+9 -5
View File
@@ -114,15 +114,19 @@ 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-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}`} />
<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}`} />
</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>
))}
+13 -8
View File
@@ -816,7 +816,9 @@ 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[] }> {
@@ -862,9 +864,7 @@ export class AdminService {
);
}
static async createProviderAccountByType(
providerType: string
): Promise<{
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
message: string;
@@ -881,7 +881,11 @@ export class AdminService {
static async initiateProviderTopup(
providerId: number,
amount: number
): Promise<{ ok: boolean; topup_data: Record<string, unknown>; message: string }> {
): Promise<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}> {
return await apiClient.post<{
ok: boolean;
topup_data: Record<string, unknown>;
@@ -901,9 +905,10 @@ export class AdminService {
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
}
static async getProviderBalance(
providerId: number
): Promise<{ ok: boolean; balance_data: number | null | Record<string, unknown> }> {
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>;
-1
View File
@@ -18,4 +18,3 @@ export const useCurrencyStore = create<CurrencyState>()(
}
)
);
+275 -4
View File
@@ -48,6 +48,7 @@
"lucide-react": "^0.501.0",
"next": "15.3.1",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-day-picker": "^9.6.7",
@@ -66,6 +67,7 @@
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.74.4",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.25.0",
@@ -3279,6 +3281,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
@@ -3896,11 +3908,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -4272,6 +4292,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
@@ -4327,6 +4356,17 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -4356,7 +4396,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -4369,7 +4408,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -4628,6 +4666,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -4702,6 +4749,12 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -5695,6 +5748,15 @@
"node": ">= 0.4"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -6174,6 +6236,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -7288,6 +7359,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7305,7 +7385,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7347,6 +7426,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7539,6 +7627,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
@@ -7835,6 +7940,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -7985,6 +8105,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -8226,6 +8352,26 @@
"node": ">=10.0.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8339,6 +8485,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -8899,6 +9057,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
@@ -8931,6 +9095,113 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -52,6 +52,7 @@
"lucide-react": "^0.501.0",
"next": "15.3.1",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-day-picker": "^9.6.7",
@@ -70,6 +71,7 @@
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.74.4",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.25.0",
+7998
View File
File diff suppressed because it is too large Load Diff