Compare commits

...

51 Commits

Author SHA1 Message Date
Shroominic
29129f8953 prettier 2025-12-11 12:56:29 +08:00
Shroominic
1e37c42ea0 fix ui build 2025-12-11 12:51:27 +08:00
Cursor Agent
2cc5063dee Merge branch 'origin/v0.2.1' into lightning to resolve conflicts 2025-12-11 02:59:20 +00:00
shroominic
e4eda59e6a Merge pull request #256 from Routstr/improve-console-logging
Improve console logging
2025-12-11 10:54:05 +08:00
shroominic
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
Shroominic
5738b1bd99 rm hardcoded excluded models 2025-12-11 10:31:51 +08:00
shroominic
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
shroominic
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
21340b2de1 check for valid_pricing 2025-12-10 09:47:13 +08:00
Shroominic
a150d38df7 Merge branch 'v0.2.1' into model-fetch-issue 2025-12-10 09:27:31 +08:00
Shroominic
02ca36141e fix linting typing/errors 2025-12-09 16:12:41 +08:00
Shroominic
20bf5e35a4 feat: add custom models and overrides 2025-12-09 15:59:07 +08:00
Shroominic
5a67e6b4d6 ignore openrouter/bodybuilder 2025-12-09 15:39:04 +08:00
Shroominic
34d1e4b041 populate upstream_provider_id 2025-12-09 15:03:16 +08:00
Shroominic
b88ef858ee ignore ppq auto model 2025-12-09 14:38:12 +08:00
Shroominic
a363c7a0b1 feat: openrouter balance fetch + generic get_balance 2025-12-09 14:28:07 +08:00
Shroominic
4ac257db0d fix model fetching issue 2025-12-09 13:50:49 +08:00
Shroominic
fd5ec01a99 fix ppq models matching incorrectly 2025-12-09 13:50:32 +08:00
9qeklajc
bc8c08c468 open router free model breaks openrouter model fetching 2025-12-05 22:48:38 +01:00
9qeklajc
d6648d3337 Merge pull request #239 from Routstr/gemini-upstream
gemini upstream
2025-12-03 22:29:09 +01:00
9qeklajc
e7f677b315 clean up 2025-12-03 20:26:14 +01:00
9qeklajc
d7611e74c3 remove model prefix for gemini 2025-12-03 20:24:36 +01:00
9qeklajc
d0790dcb22 use openrouter model matching 2025-12-03 19:36:06 +01:00
Shroominic
2b4f71cc4f Merge branch 'v0.2.1' into gemini-upstream 2025-12-02 12:46:52 +08:00
Shroominic
80359d0854 add required openai dep 2025-12-02 12:42:33 +08:00
Shroominic
42c2ddb355 optimize async startup process 2025-12-02 12:34:50 +08:00
Shroominic
65e7702f90 merge ppq integration 2025-12-02 12:32:49 +08:00
shroominic
253f419ada Merge pull request #234 from Routstr/ppq-integration
PPQ.ai provider integration
2025-12-02 12:04:03 +08:00
shroominic
83818e1097 Merge pull request #246 from Routstr/fix-reserved–balance-race-condition
Fix reserved–balance race condition
2025-12-02 11:58:52 +08:00
Shroominic
f116a5ed38 update provider fee label 2025-12-02 11:55:39 +08:00
Shroominic
ee185f56e5 editable provider fee 2025-12-02 11:54:48 +08:00
Shroominic
b76598ea41 fix reserved balance race condition bug 2025-12-01 17:27:58 +08:00
Shroominic
432fe48f36 Merge branch 'v0.2.1' into gemini-upstream 2025-11-28 13:21:56 +09:00
Shroominic
7b6e00edd8 ruff format 2025-11-28 13:21:25 +09:00
shroominic
e59335db4d Merge pull request #233 from GitHappens2Me/fix/logging_redaction
Fixed false positives in SecurityFilter
2025-11-28 13:08:08 +09:00
Shroominic
6f00478580 fix typing issues 2025-11-28 12:59:49 +09:00
Shroominic
4ed4f610f4 fix linting 2025-11-28 12:57:05 +09:00
Shroominic
30fd369bfa Merge branch 'v0.2.1' into fix/logging_redaction 2025-11-28 12:55:18 +09:00
Shroominic
8208a870a2 fix linting errors 2025-11-28 12:54:21 +09:00
9qeklajc
5eb4a40395 fix 2025-11-24 22:24:43 +01:00
9qeklajc
95ffc612ca openai lib 2025-11-24 22:07:28 +01:00
9qeklajc
afa4a59bb6 improve performance 2025-11-24 21:20:40 +01:00
9qeklajc
12515cb0e3 clean up 2025-11-22 21:00:58 +01:00
9qeklajc
0f23335de9 first shot 2025-11-22 20:07:26 +01:00
Shroominic
bd0764ee0d easy create account and topup over the dashboard for providers that support it 2025-11-18 14:38:51 -08:00
GitHappens2Me
c5c032bd2c fixed false positive redactions and added unit tests 2025-11-18 18:58:26 +01:00
Shroominic
b717c9739a ppq upstream 2025-11-17 20:47:33 -08:00
50 changed files with 13400 additions and 743 deletions

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

View File

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

View File

@@ -20,6 +20,7 @@ dependencies = [
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
]
[dependency-groups]

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
@@ -254,7 +242,12 @@ def create_model_mappings(
# Add to unique models
base_id = get_base_model_id(model_to_use.id)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(update={"id": base_id})
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream.provider_type,
}
)
unique_models[base_id] = unique_model
# Get all aliases for this model

View File

@@ -337,7 +337,7 @@ async def pay_for_request(
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) >= cost_per_request)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
total_requests=col(ApiKey.total_requests) + 1,

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)

View File

@@ -2791,6 +2791,186 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
}
class CreateAccountRequest(BaseModel):
provider_type: str
@admin_router.post(
"/api/upstream-providers/create-account",
dependencies=[Depends(require_admin_api)],
)
async def create_provider_account_by_type(
payload: CreateAccountRequest,
) -> dict[str, object]:
"""Create a new account with a provider by provider type (before provider exists in DB)."""
from ..upstream import upstream_provider_classes
provider_class = next(
(
cls
for cls in upstream_provider_classes
if cls.provider_type == payload.provider_type
),
None,
)
if not provider_class:
raise HTTPException(status_code=404, detail="Provider type not found")
try:
account_data = await provider_class.create_account_static()
return {
"ok": True,
"account_data": account_data,
"message": "Account created successfully",
}
except NotImplementedError as e:
raise HTTPException(
status_code=400,
detail=f"Provider does not support account creation: {str(e)}",
)
except Exception as e:
logger.error(
f"Failed to create account for provider type {payload.provider_type}: {e}"
)
raise HTTPException(status_code=500, detail=str(e))
class TopupRequest(BaseModel):
amount: int
@admin_router.post(
"/api/upstream-providers/{provider_id}/topup",
dependencies=[Depends(require_admin_api)],
)
async def initiate_provider_topup(
provider_id: int, payload: TopupRequest
) -> dict[str, object]:
"""Initiate a Lightning Network top-up for the upstream provider account."""
from ..upstream.helpers import _instantiate_provider
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
try:
logger.info(
f"Initiating top-up for provider {provider_id}",
extra={"amount": payload.amount},
)
topup_data = await upstream_instance.initiate_topup(payload.amount)
logger.info(
"Top-up initiated successfully",
extra={
"provider_id": provider_id,
"invoice_id": topup_data.invoice_id,
"amount": topup_data.amount,
},
)
response_data = {
"ok": True,
"topup_data": {
"invoice_id": topup_data.invoice_id,
"payment_request": topup_data.payment_request,
"amount": topup_data.amount,
"currency": topup_data.currency,
"expires_at": topup_data.expires_at,
"checkout_url": topup_data.checkout_url,
},
"message": "Top-up initiated successfully",
}
logger.info("Returning response", extra={"response": response_data})
return response_data
except NotImplementedError as e:
logger.error(f"Provider does not support top-up: {e}")
raise HTTPException(
status_code=400, detail=f"Provider does not support top-up: {str(e)}"
)
except Exception as e:
logger.error(
f"Failed to initiate top-up for provider {provider_id}: {e}",
extra={"error_type": type(e).__name__, "error": str(e)},
)
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get(
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
dependencies=[Depends(require_admin_api)],
)
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
"""Check the status of a Lightning Network top-up invoice."""
from ..upstream.helpers import _instantiate_provider
from ..upstream.ppqai import PPQAIUpstreamProvider
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
if not isinstance(upstream_instance, PPQAIUpstreamProvider):
raise HTTPException(
status_code=400,
detail="Provider does not support top-up status checking",
)
try:
paid = await upstream_instance.check_topup_status(invoice_id)
return {"ok": True, "paid": paid}
except Exception as e:
logger.error(
f"Failed to check top-up status for provider {provider_id}: {e}"
)
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get(
"/api/upstream-providers/{provider_id}/balance",
dependencies=[Depends(require_admin_api)],
)
async def get_provider_balance(provider_id: int) -> dict[str, object]:
"""Get the current account balance for the upstream provider."""
from ..upstream.helpers import _instantiate_provider
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
try:
balance_data = await upstream_instance.get_balance()
return {"ok": True, "balance_data": balance_data}
except NotImplementedError as e:
raise HTTPException(
status_code=400,
detail=f"Provider does not support balance checking: {str(e)}",
)
except Exception as e:
logger.error(f"Failed to fetch balance for provider {provider_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get(
"/api/openrouter-presets",
dependencies=[Depends(require_admin_api)],

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")

View File

@@ -192,21 +192,24 @@ class SecurityFilter(logging.Filter):
"""Filter out sensitive information from log records."""
try:
message = record.getMessage()
standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
r"nsec[a-z0-9]+", # Nostr Public / Private Key
]
for pattern in standalone_patterns:
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
for key in self.SENSITIVE_KEYS:
if key in message.lower():
patterns = [
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
key_patterns = [
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
]
for pattern in patterns:
for pattern in key_patterns:
message = re.sub(
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
)
record.msg = message
record.args = ()
@@ -335,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 [],
@@ -357,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,

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
@@ -80,8 +77,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
from ..proxy import get_upstreams
from ..upstream.helpers import refresh_upstreams_models_periodically
await _update_prices()
await initialize_upstreams()
_update_prices_task = asyncio.create_task(_update_prices())
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
@@ -92,11 +89,21 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
nip91_task = asyncio.create_task(announce_provider())
providers_task = asyncio.create_task(providers_cache_refresher())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
yield
except asyncio.CancelledError:
# Expected during shutdown
pass
except Exception as e:
logger.error(
"Application startup failed",

270
routstr/lightning.py Normal file
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()

View File

@@ -2,7 +2,6 @@ import asyncio
import json
import random
from pathlib import Path
from typing import Final
from urllib.request import urlopen
import httpx
@@ -20,14 +19,6 @@ logger = get_logger(__name__)
models_router = APIRouter()
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
"openrouter/auto",
"google/gemini-2.5-pro-exp-03-25",
"opengvlab/internvl3-78b",
"openrouter/sonoma-dusk-alpha",
"openrouter/sonoma-sky-alpha",
}
class Architecture(BaseModel):
modality: str
@@ -40,10 +31,12 @@ class Architecture(BaseModel):
class Pricing(BaseModel):
prompt: float
completion: float
request: float
image: float
web_search: float
internal_reasoning: float
request: float = 0.0
image: float = 0.0
web_search: float = 0.0
internal_reasoning: float = 0.0
input_cache_read: float = 0.0
input_cache_write: float = 0.0
max_prompt_cost: float = 0.0 # in sats not msats
max_completion_cost: float = 0.0 # in sats not msats
max_cost: float = 0.0 # in sats not msats
@@ -67,7 +60,7 @@ class Model(BaseModel):
per_request_limits: dict | None = None
top_provider: TopProvider | None = None
enabled: bool = True
upstream_provider_id: int | None = None
upstream_provider_id: int | str | None = None
canonical_slug: str | None = None
alias_ids: list[str] | None = None
@@ -75,6 +68,27 @@ class Model(BaseModel):
return hash(self.id)
def _has_valid_pricing(model: dict) -> bool:
"""Check if model has valid pricing (not free, no negative values)."""
pricing = model.get("pricing", {})
if not pricing:
return False
try:
prompt = float(pricing.get("prompt", 0))
completion = float(pricing.get("completion", 0))
except (ValueError, TypeError):
return False
if prompt < 0 or completion < 0:
return False
if prompt == 0 and completion == 0:
return False
return True
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Fetches model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
@@ -96,10 +110,10 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if (
"(free)" in model.get("name", "")
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
if "(free)" in model.get("name", ""):
continue
if not _has_valid_pricing(model):
continue
models_data.append(model)
@@ -133,10 +147,10 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if (
"(free)" in model.get("name", "")
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
if "(free)" in model.get("name", ""):
continue
if not _has_valid_pricing(model):
continue
models_data.append(model)
@@ -200,7 +214,22 @@ def load_models() -> list[Model]:
return []
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
return [Model(**model) for model in models_data] # type: ignore
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(

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:

View File

@@ -2,24 +2,28 @@ from .anthropic import AnthropicUpstreamProvider
from .azure import AzureUpstreamProvider
from .base import BaseUpstreamProvider
from .fireworks import FireworksUpstreamProvider
from .gemini import GeminiUpstreamProvider
from .generic import GenericUpstreamProvider
from .groq import GroqUpstreamProvider
from .ollama import OllamaUpstreamProvider
from .openai import OpenAIUpstreamProvider
from .openrouter import OpenRouterUpstreamProvider
from .perplexity import PerplexityUpstreamProvider
from .ppqai import PPQAIUpstreamProvider
from .xai import XAIUpstreamProvider
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
AnthropicUpstreamProvider,
AzureUpstreamProvider,
FireworksUpstreamProvider,
GeminiUpstreamProvider,
GenericUpstreamProvider,
GroqUpstreamProvider,
OllamaUpstreamProvider,
OpenAIUpstreamProvider,
OpenRouterUpstreamProvider,
PerplexityUpstreamProvider,
PPQAIUpstreamProvider,
XAIUpstreamProvider,
]
"""List of all upstream classes"""

View File

@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Mapping
import httpx
from fastapi import BackgroundTasks, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from ..auth import adjust_payment_for_tokens
from ..core import get_logger
@@ -37,6 +38,17 @@ from ..wallet import recieve_token, send_token
logger = get_logger(__name__)
class TopupData(BaseModel):
"""Universal top-up data schema for Lightning Network invoices."""
invoice_id: str
payment_request: str
amount: int
currency: str
expires_at: int | None = None
checkout_url: str | None = None
class BaseUpstreamProvider:
"""Provider for forwarding requests to an upstream AI service API."""
@@ -87,7 +99,7 @@ class BaseUpstreamProvider:
"""Get metadata about this provider type for API responses.
Returns:
Dict with provider type metadata including id, name, default_base_url, fixed_base_url, platform_url
Dict with provider type metadata including id, name, default_base_url, fixed_base_url, platform_url, can_create_account, can_topup, can_show_balance
"""
return {
"id": cls.provider_type,
@@ -95,6 +107,9 @@ class BaseUpstreamProvider:
"default_base_url": cls.default_base_url or "",
"fixed_base_url": bool(cls.default_base_url),
"platform_url": cls.platform_url,
"can_create_account": False,
"can_topup": False,
"can_show_balance": False,
}
def prepare_headers(self, request_headers: dict) -> dict:
@@ -143,7 +158,6 @@ class BaseUpstreamProvider:
# Explicitly define the list of supported compression encodings
headers["accept-encoding"] = "gzip, deflate, br, identity"
logger.debug(
"Headers prepared for upstream",
extra={
@@ -512,7 +526,7 @@ class BaseUpstreamProvider:
)
await finalize_without_usage()
raise
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
@@ -521,7 +535,7 @@ class BaseUpstreamProvider:
return StreamingResponse(
stream_with_cost(max_cost_for_model),
status_code=response.status_code,
headers=response_headers,
headers=response_headers,
)
async def handle_non_streaming_chat_completion(
@@ -1712,7 +1726,6 @@ class BaseUpstreamProvider:
Returns:
List of Model objects with pricing
"""
logger.debug(f"Fetching models for {self.provider_type or self.base_url}")
try:
or_models, provider_models_response = await asyncio.gather(
@@ -1739,18 +1752,9 @@ class BaseUpstreamProvider:
else:
not_found_models.append(model_id)
logger.info(
"Fetched models for provider",
extra={
"provider": self.provider_type or self.base_url,
"found_count": len(found_models),
"not_found_count": len(not_found_models),
},
)
if not_found_models:
logger.debug(
"Models not found in OpenRouter",
f"({len(not_found_models)}/{len(provider_model_ids)}) unmatched models for {self.provider_type or self.base_url}",
extra={"not_found_models": not_found_models},
)
@@ -1818,10 +1822,7 @@ class BaseUpstreamProvider:
self._models_cache = models_with_fees
self._models_by_id = {m.id: m for m in self._models_cache}
logger.info(
f"Refreshed models cache for {self.provider_type or self.base_url}",
extra={"model_count": len(models)},
)
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.provider_type or self.base_url}",
@@ -1846,3 +1847,60 @@ class BaseUpstreamProvider:
Model object or None if not found
"""
return self._models_by_id.get(model_id)
@classmethod
async def create_account_static(cls) -> dict[str, object]:
"""Create a new account with the provider (class method, no instance needed).
Returns:
Dict with account creation details including api_key
Raises:
NotImplementedError: If provider does not support account creation
"""
raise NotImplementedError(
f"Provider {cls.provider_type} does not support account creation"
)
async def create_account(self) -> dict[str, object]:
"""Create a new account with the provider.
Returns:
Dict with account creation details including api_key
Raises:
NotImplementedError: If provider does not support account creation
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support account creation"
)
async def initiate_topup(self, amount: int) -> TopupData:
"""Initiate a Lightning Network top-up for the provider account.
Args:
amount: Amount in currency units to top up
Returns:
TopupData with standardized invoice information
Raises:
NotImplementedError: If provider does not support top-up
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support top-up"
)
async def get_balance(self) -> float | None:
"""Get the current account balance from the provider.
Returns:
Float representing the balance amount, or None if not supported/available.
Typically in USD or the provider's credit unit.
Raises:
NotImplementedError: If provider does not support balance checking (default behavior)
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support balance checking"
)

View File

@@ -0,0 +1,3 @@
from .gemini import GeminiClient
__all__ = ["GeminiClient"]

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator
class BaseAPIClient(ABC):
"""Base class for AI provider API clients."""
def __init__(self, api_key: str, base_url: str | None = None):
self.api_key = api_key
self.base_url = base_url
@abstractmethod
async def generate_content(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Generate content non-streaming."""
pass
@abstractmethod
def generate_content_stream(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
pass
@abstractmethod
async def list_models(self) -> list[dict[str, Any]]:
"""List available models."""
pass

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any, AsyncGenerator
from openai import AsyncOpenAI
from .base import BaseAPIClient
class GeminiClient(BaseAPIClient):
"""Gemini API client using OpenAI compatibility layer."""
def __init__(self, api_key: str, base_url: str | None = None):
super().__init__(api_key, base_url)
self.client = AsyncOpenAI(
api_key=api_key,
base_url=base_url
or "https://generativelanguage.googleapis.com/v1beta/openai/",
)
async def generate_content(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
from openai import NOT_GIVEN
response = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
)
return response.model_dump()
async def generate_content_stream(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
from openai import NOT_GIVEN
usage_callback = kwargs.get("usage_callback")
completion_callback = kwargs.get("completion_callback")
stream = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
stream=True,
stream_options={"include_usage": True},
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
)
final_usage = None
async for chunk in stream:
chunk_data = chunk.model_dump()
if chunk.usage:
final_usage = chunk.usage.model_dump()
if usage_callback:
usage_callback(final_usage)
yield chunk_data
if completion_callback:
await completion_callback(model, final_usage)
async def list_models(self) -> list[dict[str, Any]]:
"""List available Gemini models."""
try:
response = await self.client.models.list()
return [model.model_dump() for model in response.data]
except Exception as e:
from ...core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"Failed to list Gemini models: {e}")
return []

283
routstr/upstream/gemini.py Normal file
View File

@@ -0,0 +1,283 @@
from __future__ import annotations
import json
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from .base import BaseUpstreamProvider
from .clients.gemini import GeminiClient
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
logger = get_logger(__name__)
class GeminiUpstreamProvider(BaseUpstreamProvider):
provider_type = "gemini"
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
platform_url = "https://aistudio.google.com/app/apikey"
def __init__(
self,
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
api_key: str = "",
provider_fee: float = 1.01,
):
super().__init__(
api_key=api_key,
provider_fee=provider_fee,
base_url=base_url,
)
self._client: GeminiClient | None = None
@property
def client(self) -> GeminiClient:
"""Get or create the Gemini API client."""
if self._client is None:
self._client = GeminiClient(api_key=self.api_key)
return self._client
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "GeminiUpstreamProvider":
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Google Gemini",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
return model_id.removeprefix("gemini/")
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
# Remove provider prefix from model ID for Gemini API
if "/" in model_obj.id:
model_obj.id = model_obj.id.split("/", 1)[1]
if not path.startswith("chat/completions"):
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
if not request_body:
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
try:
openai_data = json.loads(request_body)
messages = openai_data.get("messages", [])
temperature = openai_data.get("temperature")
max_tokens = openai_data.get("max_tokens")
top_p = openai_data.get("top_p")
is_streaming = openai_data.get("stream", False)
logger.info(
"Processing Gemini request with client abstraction",
extra={
"model": model_obj.id,
"is_streaming": is_streaming,
"message_count": len(messages),
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming:
final_usage_data: dict | None = None
def usage_callback(usage_data: dict[str, Any]) -> None:
"""Callback to capture usage data during streaming"""
nonlocal final_usage_data
final_usage_data = usage_data
async def completion_callback(
model: str, usage_data: dict[str, Any] | None
) -> None:
"""Callback to handle payment when streaming completes"""
nonlocal final_usage_data
if usage_data:
final_usage_data = usage_data
payment_data = {
"model": model,
"usage": final_usage_data,
}
from ..auth import adjust_payment_for_tokens
from ..core.db import create_session
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
payment_data,
new_session,
max_cost_for_model,
)
logger.info(
"Gemini streaming payment finalized",
extra={
"cost_data": cost_data,
"usage_data": final_usage_data,
"key_hash": key.hashed_key[:8] + "...",
},
)
except Exception as cost_error:
logger.error(
"Error finalizing Gemini streaming payment",
extra={
"error": str(cost_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
response_generator = self.client.generate_content_stream(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
usage_callback=usage_callback,
completion_callback=completion_callback,
)
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
try:
async for chunk in response_generator:
sse_data = f"data: {json.dumps(chunk)}\n\n"
yield sse_data.encode()
except Exception as e:
logger.error(
"Error in Gemini streaming response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
return StreamingResponse(
stream_with_cost(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
else:
openai_format_response = await self.client.generate_content(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
)
from ..auth import adjust_payment_for_tokens
cost_data = await adjust_payment_for_tokens(
key, openai_format_response, session, max_cost_for_model
)
openai_format_response["cost"] = cost_data
logger.info(
"Gemini non-streaming payment completed",
extra={
"cost_data": cost_data,
"model": model_obj.id,
"key_hash": key.hashed_key[:8] + "...",
},
)
return Response(
content=json.dumps(openai_format_response),
media_type="application/json",
headers={"Cache-Control": "no-cache"},
)
except Exception as e:
logger.error(
"Error in Gemini forward_request",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
},
)
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
async def _fetch_provider_models(self) -> dict:
"""Fetch models from Gemini API."""
try:
models_data = await self.client.list_models()
for model in models_data:
if "id" in model and model["id"].startswith("models/"):
model["id"] = model["id"].removeprefix("models/")
return {"data": models_data}
except Exception as e:
logger.error(
f"Failed to fetch models from Gemini API: {e}",
extra={
"error": str(e),
"error_type": type(e).__name__,
"base_url": self.base_url,
},
)
return {"data": []}

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,

View File

@@ -1,5 +1,7 @@
from typing import TYPE_CHECKING
import httpx
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
@@ -42,9 +44,33 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
"can_show_balance": True,
}
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.
Returns:
Float representing the balance amount (in credits/USD), or None if unavailable.
"""
url = f"{self.base_url}/credits"
headers = {"Authorization": f"Bearer {self.api_key}"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
credits_data = data.get("data", {})
total_credits = float(credits_data.get("total_credits", 0.0))
total_usage = float(credits_data.get("total_usage", 0.0))
return total_credits - total_usage
except Exception:
return None

401
routstr/upstream/ppqai.py Normal file
View File

@@ -0,0 +1,401 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from pydantic import BaseModel
from ..core.logging import get_logger
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
from .base import BaseUpstreamProvider, TopupData
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
logger = get_logger(__name__)
class PPQAIModelPricing(BaseModel):
ui: dict[str, float]
api: dict[str, float]
class PPQAIModel(BaseModel):
id: str
provider: str
name: str
created_at: int
context_length: int
pricing: PPQAIModelPricing
popular: bool
class PPQAIUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider for PPQ.AI API with Lightning Network top-up support."""
provider_type = "ppqai"
default_base_url = "https://api.ppq.ai"
platform_url = "https://ppq.ai/api-docs"
IGNORED_MODEL_IDS: list[str] = ["auto"]
def __init__(self, api_key: str, provider_fee: float = 1.0):
super().__init__(
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "PPQAIUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "PPQ.AI",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
"can_create_account": True,
"can_topup": True,
"can_show_balance": True,
}
def transform_model_name(self, model_id: str) -> str:
return model_id
@classmethod
async def create_account_static(cls) -> dict[str, object]:
"""Create a new PPQ.AI account without requiring an instance.
Returns:
Dict containing 'credit_id' and 'api_key' for the new account.
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{cls.default_base_url}/accounts/create"
logger.info("Creating new PPQ.AI account", extra={"url": url})
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url)
response.raise_for_status()
account_data = response.json()
logger.info(
"Successfully created PPQ.AI account",
extra={
"credit_id": account_data.get("credit_id"),
"has_api_key": bool(account_data.get("api_key")),
},
)
return account_data
async def fetch_models(self) -> list[Model]:
"""Fetch models from PPQ.AI API."""
url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
models_data = data.get("data", [])
or_models = [
Model(**model) # type: ignore
for model in await async_fetch_openrouter_models()
]
models = []
for model_data in models_data:
try:
ppqai_model = PPQAIModel.parse_obj(model_data)
if ppqai_model.id in self.IGNORED_MODEL_IDS:
continue
or_model = next(
(
model
for model in or_models
if (model.id == ppqai_model.id)
or (model.id.split("/")[-1] == ppqai_model.id)
or (model.id == ppqai_model.id.split("/")[-1])
),
None,
)
if or_model:
if input_price := ppqai_model.pricing.api.get(
"input_per_1M"
):
or_model.pricing.prompt = input_price / 1_000_000
if output_price := ppqai_model.pricing.api.get(
"output_per_1M"
):
or_model.pricing.completion = output_price / 1_000_000
if cl := ppqai_model.context_length:
or_model.context_length = cl
models.append(or_model)
else:
input_price = ppqai_model.pricing.api.get(
"input_per_1M", 0.0
)
output_price = ppqai_model.pricing.api.get(
"output_per_1M", 0.0
)
models.append(
Model(
id=ppqai_model.id,
name=ppqai_model.name,
created=ppqai_model.created_at // 1000,
description=f"{ppqai_model.provider} model",
context_length=ppqai_model.context_length,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="Unknown",
instruct_type=None,
),
pricing=Pricing(
prompt=input_price / 1_000_000,
completion=output_price / 1_000_000,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
),
)
)
except Exception as e:
logger.warning(
"Failed to parse PPQ.AI model",
extra={
"model_id": model_data.get("id", "unknown"),
"error": str(e),
"error_type": type(e).__name__,
},
)
return models
except Exception as e:
logger.error(
"Error fetching models from PPQ.AI",
extra={"error": str(e), "error_type": type(e).__name__},
)
return []
async def create_account(self) -> dict[str, object]:
"""Create a new PPQ.AI account.
Returns:
Dict containing 'credit_id' and 'api_key' for the new account.
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{self.base_url}/accounts/create"
logger.info("Creating new PPQ.AI account", extra={"url": url})
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url)
response.raise_for_status()
account_data = response.json()
logger.info(
"Successfully created PPQ.AI account",
extra={
"credit_id": account_data.get("credit_id"),
"has_api_key": bool(account_data.get("api_key")),
},
)
return account_data
async def create_lightning_topup(
self, amount: int, currency: str
) -> dict[str, object]:
"""Create a Lightning Network top-up invoice for this account.
Args:
amount: Amount to top up (in the specified currency)
currency: Currency for the top-up (default: "USD")
Returns:
Dict containing invoice details including 'invoice_id', 'payment_request', etc.
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{self.base_url}/topup/create/btc-lightning"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {"amount": amount, "currency": currency}
logger.info(
"Creating Lightning top-up invoice",
extra={"url": url, "amount": amount, "currency": currency},
)
async with httpx.AsyncClient(timeout=30.0) as client:
print(f"Payload: {payload}", "sending to", url)
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
invoice_data = response.json()
logger.info(
"Successfully created Lightning top-up invoice",
extra={
"invoice_id": invoice_data.get("invoice_id"),
"amount": amount,
"currency": currency,
},
)
return invoice_data
async def check_topup_status(self, invoice_id: str) -> bool:
"""Check the status of a Lightning top-up invoice.
Args:
invoice_id: The invoice ID to check
Returns:
True if the invoice is paid (status == "Settled"), False otherwise
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{self.base_url}/topup/status/{invoice_id}"
headers = {"Authorization": f"Bearer {self.api_key}"}
logger.debug(
"Checking Lightning top-up status",
extra={"url": url, "invoice_id": invoice_id},
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
status_data = response.json()
is_paid = status_data.get("status") == "Settled"
logger.debug(
"Retrieved Lightning top-up status",
extra={
"invoice_id": invoice_id,
"status": status_data.get("status"),
"is_paid": is_paid,
},
)
return is_paid
async def initiate_topup(self, amount: int) -> TopupData:
"""Initiate a Lightning Network top-up for the PPQ.AI account.
Args:
amount: Amount in currency units to top up (will be sent to PPQ.AI API)
Returns:
TopupData with standardized invoice information
Raises:
httpx.HTTPStatusError: If the API request fails
"""
ppq_response = await self.create_lightning_topup(amount, "USD")
logger.info(
"PPQ.AI top-up response",
extra={
"ppq_response": ppq_response,
"invoice_id": ppq_response.get("invoice_id"),
"has_lightning_invoice": "lightning_invoice" in ppq_response,
},
)
expires_at_value = ppq_response.get("expires_at")
checkout_url_value = ppq_response.get("checkout_url")
topup_data = TopupData(
invoice_id=str(ppq_response["invoice_id"]),
payment_request=str(ppq_response["lightning_invoice"]),
amount=int(ppq_response["amount"])
if isinstance(ppq_response["amount"], (int, float, str))
else 0,
currency=str(ppq_response["currency"]),
expires_at=int(expires_at_value)
if isinstance(expires_at_value, (int, float, str))
and expires_at_value is not None
else None,
checkout_url=str(checkout_url_value)
if checkout_url_value is not None
else None,
)
logger.info(
"Created TopupData",
extra={
"invoice_id": topup_data.invoice_id,
"payment_request_length": len(topup_data.payment_request),
"amount": topup_data.amount,
},
)
return topup_data
async def get_balance(self) -> float | None:
"""Get the current account balance from PPQ.AI.
Returns:
Float representing the balance amount (in USD), or None if unavailable.
Raises:
httpx.HTTPStatusError: If the API request fails
"""
data = await self.check_balance()
balance = data.get("balance")
if isinstance(balance, (int, float)):
return float(balance)
return None
async def check_balance(self) -> dict[str, object]:
"""Check the account balance for this PPQ.AI account.
Returns:
Dict containing balance information
Raises:
httpx.HTTPStatusError: If the API request fails.
"""
url = f"{self.base_url}/credits/balance"
headers = {"Authorization": f"Bearer {self.api_key}"}
logger.debug("Checking PPQ.AI account balance", extra={"url": url})
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, headers=headers, json={})
response.raise_for_status()
balance_data = response.json()
logger.debug(
"Retrieved PPQ.AI account balance",
extra={"balance": balance_data.get("balance")},
)
return balance_data

View File

@@ -379,4 +379,4 @@ async def test_info_endpoints_response_consistency(
# Model IDs should be the same
first_ids = {m["id"] for m in first_models}
response_ids = {m["id"] for m in models}
assert first_ids == response_ids
assert first_ids == response_ids

View File

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

View File

@@ -0,0 +1,118 @@
"""Unit tests for the logging SecurityFilter.
This module tests that the SecurityFilter correctly identifies and redacts
sensitive information from log messages without causing false positives.
"""
import logging
from collections.abc import Callable
import pytest
from routstr.core.logging import SecurityFilter
@pytest.fixture
def security_filter() -> SecurityFilter:
"""Provide an instance of the SecurityFilter for testing."""
return SecurityFilter()
@pytest.fixture
def filter_message(security_filter: SecurityFilter) -> Callable[[str], str]:
"""A helper fixture to apply the filter to a message string."""
def _filter(msg: str) -> str:
record = logging.LogRecord(
name="test_logger",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
security_filter.filter(record)
return record.getMessage()
return _filter
def test_redacts_unquoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
"""Test that an unquoted key-value pair is correctly redacted."""
original = "Processing request with api_key=sk-12345abcdef"
expected = "Processing request with api_key: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_quoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
"""Test that a quoted token is correctly redacted."""
original = 'User authenticated with token="cashuA123abc"'
expected = "User authenticated with token: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_bearer_token(filter_message: Callable[[str], str]) -> None:
"""Test that a Bearer token of sufficient length is redacted."""
original = "Authorization: Bearer abc1234567890xyzabcdefg"
expected = "Authorization: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_cashu_token(filter_message: Callable[[str], str]) -> None:
"""Test that a Cashu token is redacted."""
original = "Received cashuTOKENeyJ0b2tlbiI6W3siaWQiOiI"
expected = "Received [REDACTED]"
assert filter_message(original) == expected
def test_redacts_nsec_key(filter_message: Callable[[str], str]) -> None:
"""Test that a full-length Nostr private key is redacted."""
original = "Private key is nsec1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f"
expected = "Private key is [REDACTED]"
assert filter_message(original) == expected
def test_ignores_non_sensitive_message(filter_message: Callable[[str], str]) -> None:
"""Test that a message with no sensitive data is left untouched."""
original = "No token pricing configured, using base cost"
expected = "No token pricing configured, using base cost"
assert filter_message(original) == expected
def test_multiple_secrets_in_one_message(filter_message: Callable[[str], str]) -> None:
"""Test that multiple different secrets in one message are all redacted."""
original = 'Auth with Bearer abcdefghijklmnopqrstuvwxyz and api_key="sk-12345"'
expected = "Auth with [REDACTED] and api_key: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_key_with_no_value(filter_message: Callable[[str], str]) -> None:
"""Test that a key with no value is not redacted."""
original = "Request contains api_key and secret."
expected = "Request contains api_key and secret."
assert filter_message(original) == expected
def test_redacts_key_value_with_spaces(filter_message: Callable[[str], str]) -> None:
"""Test that key-value pairs with extra spaces are correctly redacted."""
original = "Auth info: api_key = 'sk-12345'"
expected = "Auth info: api_key: [REDACTED]"
assert filter_message(original) == expected
def test_is_case_insensitive_for_keys(filter_message: Callable[[str], str]) -> None:
"""Test that key matching is case-insensitive."""
original = "TOKEN=sk-abcdef12345"
expected = "token: [REDACTED]"
assert filter_message(original) == expected
def test_is_case_insensitive_for_standalone(
filter_message: Callable[[str], str],
) -> None:
"""Test that standalone matching is case-insensitive."""
original = "Using NSEC1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f and CaShuA123abc"
expected = "Using [REDACTED] and [REDACTED]"
assert filter_message(original) == expected

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>

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

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' ? (

View File

@@ -204,47 +204,59 @@ export default function ModelsPage() {
</div>
</div>
</div>
{totalModels === 0 ? (
<Alert>
{totalModels === 0 && (
<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>
</div>
</AlertDescription>
</Alert>
) : (
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
)}
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
</div>
</TabsContent>
);

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}

View File

@@ -18,7 +18,9 @@ import {
UpstreamProvider,
CreateUpstreamProvider,
UpdateUpstreamProvider,
AdminModel,
} from '@/lib/api/services/admin';
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
@@ -51,9 +53,329 @@ import {
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
function ProviderBalance({
providerId,
platformUrl,
}: {
providerId: number;
platformUrl?: string | null;
}) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
const [topupError, setTopupError] = useState('');
const [isHovered, setIsHovered] = useState(false);
const [invoiceData, setInvoiceData] = useState<{
payment_request: string;
invoice_id: string;
} | null>(null);
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
null
);
const queryClient = useQueryClient();
const {
data: balanceData,
isLoading,
error,
} = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
refetchOnWindowFocus: true,
retry: 1,
});
const { data: statusData } = useQuery({
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
queryFn: () =>
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
enabled: !!invoiceData && paymentStatus === 'pending',
refetchInterval: 2000,
});
useEffect(() => {
if (statusData?.paid === true) {
setPaymentStatus('paid');
queryClient.invalidateQueries({
queryKey: ['provider-balance', providerId],
});
toast.success('Payment received!', {
description: 'Your balance has been updated.',
});
}
}, [statusData, queryClient, providerId]);
const topupMutation = useMutation({
mutationFn: async (amount: number) => {
console.log('Calling top-up API with:', { providerId, amount });
try {
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
console.log('API returned:', result);
return result;
} catch (err) {
console.error('API call failed:', err);
throw err;
}
},
onSuccess: (data) => {
console.log('Top-up response:', data);
console.log('Type of data:', typeof data);
console.log('Keys in data:', Object.keys(data || {}));
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
setInvoiceData({
payment_request: data.topup_data.payment_request as string,
invoice_id: data.topup_data.invoice_id as string,
});
setPaymentStatus('pending');
} else {
console.error('Missing invoice data:', data);
console.error('topup_data:', data?.topup_data);
toast.error('No invoice returned from provider');
setIsTopupDialogOpen(false);
}
},
onError: (error: Error) => {
console.error('Top-up mutation error:', error);
toast.error(`Failed to initiate top-up: ${error.message}`);
},
});
const handleTopup = () => {
// If no dialog open logic (which depends on API implementation),
// we check if we should redirect or open dialog based on available info
// But since this function is called inside the dialog, we might want to change
// how the "Top Up" button behaves instead.
const amount = parseFloat(topupAmount);
if (isNaN(amount)) {
setTopupError('Please enter a valid amount');
return;
}
if (amount < 1 || amount > 500) {
setTopupError('Amount must be between $1 and $500');
return;
}
topupMutation.mutate(amount);
};
const handleTopUpClick = () => {
// Check if the provider supports direct topup (currently only PPQ.AI effectively)
// We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability
// For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented),
// we should redirect.
// However, the prompt asks to redirect if topup is not implemented.
// The backend throws 500/400 if not implemented.
// A better approach is to check if we have a platform URL and maybe redirect there
// if we know it's not supported.
// BUT, we don't know for sure if it's supported without checking metadata or trying.
// Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance".
// Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect?
// Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect?
// The user specifically mentioned "like in openrouter".
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
const handleCloseDialog = () => {
setIsTopupDialogOpen(false);
setTopupAmount('');
setTopupError('');
setInvoiceData(null);
setPaymentStatus(null);
};
if (isLoading) {
return <Skeleton className='h-9 w-24' />;
}
if (error || !balanceData?.ok || !balanceData.balance_data) {
return null;
}
const balance = balanceData.balance_data;
let displayValue = 'N/A';
if (typeof balance === 'number') {
displayValue = `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
// Legacy support for object response
const b = balance as Record<string, unknown>;
if (typeof b.balance === 'number') {
displayValue = `$${b.balance.toFixed(2)}`;
} else if (typeof b.balance === 'string') {
displayValue = b.balance;
} else if (b.amount !== undefined) {
displayValue = `$${Number(b.amount).toFixed(2)}`;
}
}
return (
<>
<Button
variant='outline'
size='sm'
onClick={handleTopUpClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='w-full font-mono sm:w-auto'
>
{isHovered ? 'Top Up' : displayValue}
</Button>
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>
{paymentStatus === 'paid'
? 'Payment Confirmed!'
: 'Top Up Balance'}
</DialogTitle>
<DialogDescription>
{paymentStatus === 'paid'
? 'Your account balance has been updated.'
: invoiceData
? 'Scan the QR code or copy the Lightning invoice to pay.'
: 'Enter the amount you want to add to your account balance.'}
</DialogDescription>
</DialogHeader>
{paymentStatus === 'paid' ? (
<div className='flex flex-col items-center gap-4 py-6'>
<div className='rounded-full bg-green-100 p-3 dark:bg-green-900'>
<svg
className='h-12 w-12 text-green-600 dark:text-green-400'
fill='none'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path d='M5 13l4 4L19 7'></path>
</svg>
</div>
<p className='text-center font-semibold'>Top-up successful!</p>
</div>
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'>
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
alt='Lightning Invoice QR Code'
className='h-64 w-64'
/>
</div>
<div className='w-full space-y-2'>
<Label htmlFor='invoice'>Lightning Invoice</Label>
<div className='flex gap-2'>
<Input
id='invoice'
value={invoiceData.payment_request}
readOnly
className='font-mono text-xs'
/>
<Button
size='sm'
variant='outline'
onClick={() => {
navigator.clipboard.writeText(
invoiceData.payment_request
);
toast.success('Invoice copied to clipboard!');
}}
>
Copy
</Button>
</div>
</div>
{paymentStatus === 'pending' && (
<p className='text-muted-foreground text-center text-sm'>
Waiting for payment...
</p>
)}
</div>
) : (
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='topup_amount'>Amount (USD)</Label>
<Input
id='topup_amount'
type='number'
placeholder='Enter amount (1-500)'
value={topupAmount}
onChange={(e) => {
setTopupAmount(e.target.value);
setTopupError('');
}}
min='1'
max='500'
step='0.01'
/>
{topupError && (
<p className='text-sm text-red-600 dark:text-red-400'>
{topupError}
</p>
)}
</div>
</div>
)}
<DialogFooter>
{paymentStatus === 'paid' ? (
<Button onClick={handleCloseDialog} className='w-full'>
Done
</Button>
) : invoiceData ? (
<Button
variant='outline'
onClick={handleCloseDialog}
className='w-full'
>
Cancel
</Button>
) : (
<>
<Button variant='outline' onClick={handleCloseDialog}>
Cancel
</Button>
<Button
onClick={handleTopup}
disabled={topupMutation.isPending || !topupAmount}
>
{topupMutation.isPending
? 'Processing...'
: 'Generate Invoice'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default function ProvidersPage() {
const queryClient = useQueryClient();
const [editingProvider, setEditingProvider] =
@@ -64,6 +386,18 @@ export default function ProvidersPage() {
new Set()
);
const [viewingModels, setViewingModels] = useState<number | null>(null);
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
const [modelDialogState, setModelDialogState] = useState<{
isOpen: boolean;
providerId: number | null;
mode: 'create' | 'edit' | 'override';
initialData?: AdminModel | null;
}>({
isOpen: false,
providerId: null,
mode: 'create',
initialData: null,
});
const [formData, setFormData] = useState<CreateUpstreamProvider>({
provider_type: 'openrouter',
@@ -71,8 +405,13 @@ export default function ProvidersPage() {
api_key: '',
api_version: null,
enabled: true,
provider_fee: 1.06,
});
const getProviderFeePlaceholder = (type: string) => {
return type === 'openrouter' ? 'Default: 1.06 (6%)' : 'Default: 1.01 (1%)';
};
const { data: providerTypes = [] } = useQuery({
queryKey: ['provider-types'],
queryFn: () => AdminService.getProviderTypes(),
@@ -138,6 +477,32 @@ export default function ProvidersPage() {
},
});
const handleCreateAccount = async () => {
setIsCreatingAccount(true);
try {
const response = await AdminService.createProviderAccountByType(
formData.provider_type
);
if (response.ok && response.account_data.api_key) {
setFormData({
...formData,
api_key: String(response.account_data.api_key),
});
toast.success(
'Account created successfully! API key has been filled in.'
);
} else {
toast.success('Account created, but no API key returned.');
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
toast.error(`Failed to create account: ${errorMessage}`);
} finally {
setIsCreatingAccount(false);
}
};
const resetForm = () => {
setFormData({
provider_type: 'openrouter',
@@ -149,7 +514,11 @@ export default function ProvidersPage() {
};
const handleCreate = () => {
createMutation.mutate(formData);
const data = { ...formData };
if (data.provider_fee === undefined || data.provider_fee === null) {
data.provider_fee = data.provider_type === 'openrouter' ? 1.06 : 1.01;
}
createMutation.mutate(data);
};
const handleEdit = (provider: UpstreamProvider) => {
@@ -160,6 +529,7 @@ export default function ProvidersPage() {
api_key: '',
api_version: provider.api_version || null,
enabled: provider.enabled,
provider_fee: provider.provider_fee,
});
setIsEditDialogOpen(true);
};
@@ -171,6 +541,7 @@ export default function ProvidersPage() {
base_url: formData.base_url,
api_version: formData.api_version,
enabled: formData.enabled,
provider_fee: formData.provider_fee,
};
if (formData.api_key) {
updateData.api_key = formData.api_key;
@@ -199,6 +570,16 @@ export default function ProvidersPage() {
return providerType?.platform_url || null;
};
const canCreateAccount = (type: string) => {
const providerType = providerTypes.find((pt) => pt.id === type);
return providerType?.can_create_account || false;
};
const canShowBalance = (type: string) => {
const providerType = providerTypes.find((pt) => pt.id === type);
return providerType?.can_show_balance || false;
};
const toggleProviderExpansion = (providerId: number) => {
const newExpanded = new Set(expandedProviders);
if (newExpanded.has(providerId)) {
@@ -214,6 +595,33 @@ export default function ProvidersPage() {
}
};
const handleAddModel = (providerId: number) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'create',
initialData: null,
});
};
const handleEditModel = (providerId: number, model: AdminModel) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'edit',
initialData: model,
});
};
const handleOverrideModel = (providerId: number, model: AdminModel) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'override',
initialData: model,
});
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
@@ -253,11 +661,12 @@ export default function ProvidersPage() {
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData({
...formData,
setFormData((prev) => ({
...prev,
provider_type: value,
base_url: getDefaultBaseUrl(value),
});
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
}));
}}
>
<SelectTrigger>
@@ -292,15 +701,30 @@ export default function ProvidersPage() {
<div className='grid gap-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='api_key'>API Key</Label>
{getPlatformUrl(formData.provider_type) && (
<a
href={getPlatformUrl(formData.provider_type)!}
target='_blank'
rel='noopener noreferrer'
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
{canCreateAccount(formData.provider_type) ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={handleCreateAccount}
disabled={isCreatingAccount}
className='h-6 text-xs'
>
Get Your API Key Here
</a>
{isCreatingAccount
? 'Creating...'
: 'Create Account'}
</Button>
) : (
getPlatformUrl(formData.provider_type) && (
<a
href={getPlatformUrl(formData.provider_type)!}
target='_blank'
rel='noopener noreferrer'
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
>
Get Your API Key Here
</a>
)
)}
</div>
<Input
@@ -339,6 +763,32 @@ export default function ProvidersPage() {
/>
<Label htmlFor='enabled'>Enabled</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor='provider_fee'>
Provider Fee (Multiplier)
</Label>
<Input
id='provider_fee'
type='number'
step='0.001'
min='1.0'
value={formData.provider_fee || ''}
onChange={(e) =>
setFormData({
...formData,
provider_fee: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder={getProviderFeePlaceholder(
formData.provider_type
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
<DialogFooter>
<Button
@@ -410,7 +860,16 @@ export default function ProvidersPage() {
{provider.base_url}
</CardDescription>
</div>
<div className='grid grid-cols-3 gap-2 sm:flex sm:flex-nowrap'>
<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
)}
/>
)}
<Button
variant='outline'
size='sm'
@@ -473,9 +932,21 @@ export default function ProvidersPage() {
// No provided models - show custom models directly without tabs
<div className='space-y-2'>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No models configured. Add custom models to
use this provider.
<div className='flex flex-col items-center justify-center gap-2 py-4'>
<div className='text-muted-foreground text-sm'>
No models configured. Add custom models
to use this provider.
</div>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
</div>
) : (
<div className='space-y-2'>
@@ -506,9 +977,24 @@ export default function ProvidersPage() {
{model.description || model.name}
</div>
</div>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
</div>
</div>
))}
@@ -559,12 +1045,24 @@ export default function ProvidersPage() {
value='custom'
className='mt-4 space-y-2'
>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground mb-3 text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<div className='flex items-center justify-between'>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
@@ -600,9 +1098,24 @@ export default function ProvidersPage() {
model.name}
</div>
</div>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
</div>
</div>
)
@@ -637,9 +1150,25 @@ export default function ProvidersPage() {
model.name}
</div>
</div>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
handleOverrideModel(
provider.id,
model
)
}
>
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
</div>
</div>
)
@@ -674,11 +1203,12 @@ export default function ProvidersPage() {
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData({
...formData,
setFormData((prev) => ({
...prev,
provider_type: value,
base_url: getDefaultBaseUrl(value),
});
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
}));
}}
>
<SelectTrigger>
@@ -762,6 +1292,32 @@ export default function ProvidersPage() {
/>
<Label htmlFor='edit_enabled'>Enabled</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit_provider_fee'>
Provider Fee (Multiplier)
</Label>
<Input
id='edit_provider_fee'
type='number'
step='0.001'
min='1.0'
value={formData.provider_fee || ''}
onChange={(e) =>
setFormData({
...formData,
provider_fee: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder={getProviderFeePlaceholder(
formData.provider_type
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
<DialogFooter>
<Button
@@ -779,6 +1335,23 @@ export default function ProvidersPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-models', modelDialogState.providerId],
});
}}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
/>
)}
</SidebarInset>
</SidebarProvider>
);

View File

@@ -0,0 +1,956 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Loader2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
const listFromString = (value: string): string[] =>
value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
const listToString = (value: string[] | undefined | null): string =>
value && value.length > 0 ? value.join(', ') : '';
const FormSchema = z.object({
id: z.string().min(1, 'Model ID is required'),
name: z.string().min(1, 'Name is required'),
description: z.string().default(''),
context_length: z.coerce.number().min(0).default(8192),
modality: z.string().min(1, 'Modality is required'),
input_modalities_raw: z.string().default(''),
output_modalities_raw: z.string().default(''),
tokenizer: z.string().default(''),
instruct_type: z.string().default(''),
canonical_slug: z.string().default(''),
alias_ids_raw: z.string().default(''),
upstream_provider_id: z.string().default(''),
input_cost: z.coerce.number().min(0).default(0),
output_cost: z.coerce.number().min(0).default(0),
request_cost: z.coerce.number().min(0).default(0),
image_cost: z.coerce.number().min(0).default(0),
web_search_cost: z.coerce.number().min(0).default(0),
internal_reasoning_cost: z.coerce.number().min(0).default(0),
max_prompt_cost: z.coerce.number().min(0).default(0),
max_completion_cost: z.coerce.number().min(0).default(0),
max_cost: z.coerce.number().min(0).default(0),
per_request_limits_raw: z.string().default(''),
top_provider_context_length: z.coerce.number().min(0).optional(),
top_provider_max_completion_tokens: z.coerce.number().min(0).optional(),
top_provider_is_moderated: z.boolean().default(false),
enabled: z.boolean().default(true),
});
type FormData = z.output<typeof FormSchema>;
export interface AddProviderModelDialogProps {
providerId: number;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: AdminModel | null;
mode?: 'create' | 'edit' | 'override';
}
export function AddProviderModelDialog({
providerId,
isOpen,
onClose,
onSuccess,
initialData,
mode = 'create',
}: AddProviderModelDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPresetOpen, setIsPresetOpen] = useState(false);
const [selectedPresetLabel, setSelectedPresetLabel] =
useState('Select a preset');
const form = useForm<FormData>({
resolver: zodResolver(FormSchema) as never,
defaultValues: {
id: '',
name: '',
description: '',
context_length: 8192,
modality: 'text',
input_modalities_raw: 'text',
output_modalities_raw: 'text',
tokenizer: '',
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
internal_reasoning_cost: 0,
max_prompt_cost: 0,
max_completion_cost: 0,
max_cost: 0,
per_request_limits_raw: '',
top_provider_context_length: undefined,
top_provider_max_completion_tokens: undefined,
top_provider_is_moderated: false,
enabled: true,
},
});
const isOverride = useMemo(() => mode === 'override', [mode]);
const isEdit = useMemo(() => mode === 'edit', [mode]);
const { data: presets = [], isLoading: isLoadingPresets } = useQuery({
queryKey: ['openrouter-presets'],
queryFn: () => AdminService.getOpenRouterPresets(),
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (initialData) {
const architecture = initialData.architecture as Record<string, unknown>;
const pricing = initialData.pricing as Record<string, number>;
const topProvider = initialData.top_provider as Record<
string,
unknown
> | null;
form.reset({
id: initialData.id,
name: initialData.name,
description: initialData.description,
context_length: initialData.context_length,
modality:
typeof architecture?.modality === 'string'
? architecture.modality
: 'text',
input_modalities_raw: listToString(
(architecture?.input_modalities as string[]) || []
),
output_modalities_raw: listToString(
(architecture?.output_modalities as string[]) || []
),
tokenizer:
typeof architecture?.tokenizer === 'string'
? architecture.tokenizer
: '',
instruct_type:
typeof architecture?.instruct_type === 'string'
? architecture.instruct_type
: '',
canonical_slug: initialData.canonical_slug || '',
alias_ids_raw: listToString(initialData.alias_ids),
upstream_provider_id:
typeof initialData.upstream_provider_id === 'string'
? initialData.upstream_provider_id
: initialData.upstream_provider_id?.toString() || '',
input_cost: pricing?.prompt ?? 0,
output_cost: pricing?.completion ?? 0,
request_cost: pricing?.request ?? 0,
image_cost: pricing?.image ?? 0,
web_search_cost: pricing?.web_search ?? 0,
internal_reasoning_cost: pricing?.internal_reasoning ?? 0,
max_prompt_cost: pricing?.max_prompt_cost ?? 0,
max_completion_cost: pricing?.max_completion_cost ?? 0,
max_cost: pricing?.max_cost ?? 0,
per_request_limits_raw: initialData.per_request_limits
? JSON.stringify(initialData.per_request_limits, null, 2)
: '',
top_provider_context_length:
typeof topProvider?.context_length === 'number'
? topProvider.context_length
: undefined,
top_provider_max_completion_tokens:
typeof topProvider?.max_completion_tokens === 'number'
? topProvider.max_completion_tokens
: undefined,
top_provider_is_moderated:
typeof topProvider?.is_moderated === 'boolean'
? topProvider.is_moderated
: false,
enabled: initialData.enabled,
});
} else {
form.reset({
id: '',
name: '',
description: '',
context_length: 8192,
modality: 'text',
input_modalities_raw: 'text',
output_modalities_raw: 'text',
tokenizer: '',
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
internal_reasoning_cost: 0,
max_prompt_cost: 0,
max_completion_cost: 0,
max_cost: 0,
per_request_limits_raw: '',
top_provider_context_length: undefined,
top_provider_max_completion_tokens: undefined,
top_provider_is_moderated: false,
enabled: true,
});
}
}, [initialData, form, isOpen]);
const applyModelToForm = (model: AdminModel) => {
setSelectedPresetLabel(`${model.id}${model.name}`);
const architecture = model.architecture as Record<string, unknown>;
const pricing = model.pricing as Record<string, number>;
const topProvider = model.top_provider as Record<string, unknown> | null;
form.setValue('id', model.id);
form.setValue('name', model.name);
form.setValue('description', model.description || '');
form.setValue('context_length', model.context_length);
form.setValue(
'modality',
typeof architecture?.modality === 'string'
? architecture.modality
: 'text'
);
form.setValue(
'input_modalities_raw',
listToString((architecture?.input_modalities as string[]) || [])
);
form.setValue(
'output_modalities_raw',
listToString((architecture?.output_modalities as string[]) || [])
);
form.setValue(
'tokenizer',
typeof architecture?.tokenizer === 'string' ? architecture.tokenizer : ''
);
form.setValue(
'instruct_type',
typeof architecture?.instruct_type === 'string'
? architecture.instruct_type
: ''
);
form.setValue('canonical_slug', model.canonical_slug || '');
form.setValue('alias_ids_raw', listToString(model.alias_ids));
form.setValue(
'upstream_provider_id',
typeof model.upstream_provider_id === 'string'
? model.upstream_provider_id
: model.upstream_provider_id?.toString() || ''
);
form.setValue('input_cost', pricing?.prompt ?? 0);
form.setValue('output_cost', pricing?.completion ?? 0);
form.setValue('request_cost', pricing?.request ?? 0);
form.setValue('image_cost', pricing?.image ?? 0);
form.setValue('web_search_cost', pricing?.web_search ?? 0);
form.setValue('internal_reasoning_cost', pricing?.internal_reasoning ?? 0);
form.setValue('max_prompt_cost', pricing?.max_prompt_cost ?? 0);
form.setValue('max_completion_cost', pricing?.max_completion_cost ?? 0);
form.setValue('max_cost', pricing?.max_cost ?? 0);
form.setValue(
'per_request_limits_raw',
model.per_request_limits
? JSON.stringify(model.per_request_limits, null, 2)
: ''
);
form.setValue(
'top_provider_context_length',
typeof topProvider?.context_length === 'number'
? topProvider.context_length
: undefined
);
form.setValue(
'top_provider_max_completion_tokens',
typeof topProvider?.max_completion_tokens === 'number'
? topProvider.max_completion_tokens
: undefined
);
form.setValue(
'top_provider_is_moderated',
typeof topProvider?.is_moderated === 'boolean'
? topProvider.is_moderated
: false
);
form.setValue('enabled', model.enabled);
};
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
let perRequestLimits: Record<string, unknown> | null = null;
if (
data.per_request_limits_raw &&
data.per_request_limits_raw.trim().length
) {
try {
perRequestLimits = JSON.parse(data.per_request_limits_raw);
} catch {
toast.error('Per-request limits must be valid JSON');
setIsSubmitting(false);
return;
}
}
const adminModel: AdminModel = {
id: data.id,
name: data.name,
description: data.description || '',
created: Math.floor(Date.now() / 1000),
context_length: data.context_length,
architecture: {
modality: data.modality,
input_modalities: listFromString(
data.input_modalities_raw || data.modality
),
output_modalities: listFromString(
data.output_modalities_raw || data.modality
),
tokenizer: data.tokenizer || '',
instruct_type: data.instruct_type?.trim() || null,
},
pricing: {
prompt: data.input_cost,
completion: data.output_cost,
request: data.request_cost,
image: data.image_cost,
web_search: data.web_search_cost,
internal_reasoning: data.internal_reasoning_cost,
max_prompt_cost: data.max_prompt_cost,
max_completion_cost: data.max_completion_cost,
max_cost: data.max_cost,
},
per_request_limits: perRequestLimits,
top_provider:
data.top_provider_context_length ||
data.top_provider_max_completion_tokens ||
data.top_provider_is_moderated
? {
context_length: data.top_provider_context_length ?? null,
max_completion_tokens:
data.top_provider_max_completion_tokens ?? null,
is_moderated: data.top_provider_is_moderated,
}
: null,
upstream_provider_id: data.upstream_provider_id?.trim().length
? data.upstream_provider_id.trim()
: providerId,
canonical_slug: data.canonical_slug?.trim() || null,
alias_ids: listFromString(data.alias_ids_raw || ''),
enabled: data.enabled,
};
if (isEdit) {
await AdminService.updateProviderModel(providerId, data.id, adminModel);
toast.success('Model updated successfully');
} else {
await AdminService.createProviderModel(providerId, adminModel);
toast.success(
isOverride ? 'Model override created' : 'Model created successfully'
);
}
onSuccess();
onClose();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Unknown error saving model';
toast.error(`Failed to save model: ${message}`);
} finally {
setIsSubmitting(false);
}
};
const title = isEdit
? 'Edit Model'
: isOverride
? 'Override Model'
: 'Add Custom Model';
const description = isOverride
? 'Create a custom override for this upstream model'
: 'Add a new model configuration for this provider';
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[720px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-4 w-4' />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Presets</div>
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
<Popover open={isPresetOpen} onOpenChange={setIsPresetOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={isPresetOpen}
className='w-full justify-between overflow-hidden text-left text-sm'
>
<span className='truncate'>
{isLoadingPresets
? 'Loading presets...'
: selectedPresetLabel}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className='w-80 max-w-sm p-0'
align='start'
sideOffset={4}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={true}>
<CommandInput placeholder='Search presets...' />
<CommandList
className='max-h-64 overflow-y-auto overscroll-contain'
onWheel={(e) => e.stopPropagation()}
>
{isLoadingPresets ? (
<CommandEmpty>Loading presets...</CommandEmpty>
) : presets.length === 0 ? (
<CommandEmpty>No presets available.</CommandEmpty>
) : (
<CommandGroup heading='Presets'>
{presets.map((preset) => (
<CommandItem
key={preset.id}
value={preset.id}
onSelect={() => {
applyModelToForm(preset);
setIsPresetOpen(false);
}}
>
<div className='flex flex-col text-sm'>
<span className='font-medium'>{preset.id}</span>
<span className='text-muted-foreground text-xs'>
{preset.name}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
</div>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='id'
render={({ field }) => (
<FormItem>
<FormLabel>Model ID *</FormLabel>
<FormControl>
<Input
placeholder='e.g., gpt-5.1'
{...field}
disabled={isOverride || isEdit}
/>
</FormControl>
<FormDescription>
Unique identifier for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Display Name *</FormLabel>
<FormControl>
<Input placeholder='e.g., GPT-5.1' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Brief description...'
{...field}
rows={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='modality'
render={({ field }) => (
<FormItem>
<FormLabel>Modality *</FormLabel>
<FormControl>
<Input
placeholder='text, text+image->text, image->text, etc.'
{...field}
/>
</FormControl>
<FormDescription>
Composite modality label (e.g., text+image-&gt;text)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input type='number' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_modalities_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Input Modalities</FormLabel>
<FormControl>
<Input placeholder='text, image, file' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_modalities_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Output Modalities</FormLabel>
<FormControl>
<Input placeholder='text, image' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='tokenizer'
render={({ field }) => (
<FormItem>
<FormLabel>Tokenizer</FormLabel>
<FormControl>
<Input placeholder='e.g., GPT, tiktoken' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='instruct_type'
render={({ field }) => (
<FormItem>
<FormLabel>Instruct Type</FormLabel>
<FormControl>
<Input placeholder='e.g., chat, completion' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='canonical_slug'
render={({ field }) => (
<FormItem>
<FormLabel>Canonical Slug</FormLabel>
<FormControl>
<Input
placeholder='google/gemini-3-pro-preview-20251117'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='alias_ids_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Alias IDs</FormLabel>
<FormControl>
<Input placeholder='alias-1, alias-2' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='upstream_provider_id'
render={({ field }) => (
<FormItem>
<FormLabel>Upstream Provider ID</FormLabel>
<FormControl>
<Input placeholder='gemini' {...field} />
</FormControl>
<FormDescription>
Defaults to current provider if left blank
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='per_request_limits_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Per-request Limits (JSON)</FormLabel>
<FormControl>
<Textarea
placeholder='{"requests_per_min": 60}'
{...field}
rows={4}
/>
</FormControl>
<FormDescription>
JSON object; leave empty for none
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>
Pricing (USD per 1M tokens)
</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Input Cost</FormLabel>
<FormControl>
<Input type='number' step='0.01' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Output Cost</FormLabel>
<FormControl>
<Input type='number' step='0.01' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='request_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Per Request Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='image_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Image Cost (per image)</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='web_search_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Web Search Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='internal_reasoning_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Internal Reasoning Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_prompt_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Prompt Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_completion_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Total Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>Top Provider (optional)</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3'>
<FormField
control={form.control}
name='top_provider_context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input
type='number'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='top_provider_max_completion_tokens'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Tokens</FormLabel>
<FormControl>
<Input
type='number'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='top_provider_is_moderated'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
Is Moderated
</FormLabel>
<FormDescription>
Whether provider enforces moderation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Enabled</FormLabel>
<FormDescription>Enable this model for use</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{isEdit
? 'Save Changes'
: isOverride
? 'Create Override'
: 'Create Model'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,15 +2,14 @@
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 ManualModel,
type GroupSettings,
} from '@/lib/api/schemas/models';
import { AdminService, type AdminModelGroup } from '@/lib/api/services/admin';
AdminService,
type AdminModelGroup,
type AdminModel,
} from '@/lib/api/services/admin';
type ModelGroup = AdminModelGroup;
import { AddModelForm } from '@/components/AddModelForm';
import { EditModelForm } from '@/components/EditModelForm';
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { EditGroupForm } from '@/components/EditGroupForm';
import { CollectModelsDialog } from '@/components/CollectModelsDialog';
import { formatCost } from '@/lib/services/costValidation';
@@ -80,14 +79,23 @@ export function ModelSelector({
}: ModelSelectorProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [, setHoveredModelId] = useState<string | null>(null);
const [isAddFormOpen, setIsAddFormOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | null>(null);
const [editingGroup, setEditingGroup] = useState<{
provider: string;
models: Model[];
groupData: ModelGroup;
} | null>(null);
const [isCollectDialogOpen, setIsCollectDialogOpen] = useState(false);
const [modelDialogState, setModelDialogState] = useState<{
isOpen: boolean;
providerId: number | null;
mode: 'create' | 'edit' | 'override';
initialData?: AdminModel | null;
}>({
isOpen: false,
providerId: null,
mode: 'create',
initialData: null,
});
// Bulk selection state
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
@@ -492,44 +500,104 @@ export function ModelSelector({
return hasIndividualApiKey || hasIndividualUrl;
};
// Handle manual model addition
const handleAddModel = async (newModel: ManualModel) => {
try {
// Find or create the provider group
let providerGroup = groups.find((g) => g.provider === newModel.provider);
const handleAddModelClick = (providerId: number) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'create',
initialData: null,
});
};
if (!providerGroup) {
providerGroup = await AdminService.createModelGroup({
provider: newModel.provider,
group_api_key: undefined,
});
}
const modelData = {
name: newModel.name,
full_name: newModel.name,
input_cost: newModel.input_cost,
output_cost: newModel.output_cost,
provider: newModel.provider,
modelType: newModel.modelType,
description: newModel.description || undefined,
contextLength: newModel.contextLength,
};
await AdminService.createModel(modelData);
await refetchModels();
toast.success(`Model "${newModel.name}" added successfully!`);
} catch (error) {
console.error('Error adding model:', error);
toast.error('Failed to add model. Please try again.');
throw error; // Re-throw to let the form handle the error
const handleEditModelClick = (model: Model) => {
if (!model.provider_id) {
toast.error('Provider ID missing for model');
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
name: model.name,
description: model.description || '',
created: new Date(model.createdAt).getTime() / 1000,
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
};
setModelDialogState({
isOpen: true,
providerId,
mode: 'edit',
initialData: adminModel,
});
};
const handleOverrideModelClick = (model: Model) => {
if (!model.provider_id) {
toast.error('Provider ID missing for model');
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
name: model.name,
description: model.description || '',
created: new Date(model.createdAt).getTime() / 1000,
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
};
setModelDialogState({
isOpen: true,
providerId,
mode: 'override',
initialData: adminModel,
});
};
// Handle model update
const handleModelUpdate = async () => {
await refetchModels();
setEditingModel(null);
};
// Handle group update
@@ -832,11 +900,18 @@ export function ModelSelector({
Delete All Overrides Permanently
</Button>
)}
{/* Model Management Actions
<Button onClick={() => setIsAddFormOpen(true)} className='gap-2'>
<Plus className='h-4 w-4' />
Add Model
</Button>
{/* Model Management Actions */}
{groupData && (
<Button
onClick={() => handleAddModelClick(parseInt(groupData.id))}
className='gap-2'
variant='outline'
>
<CheckSquare className='h-4 w-4' />
Add Custom Model
</Button>
)}
{/*
<Button
onClick={() => setIsCollectDialogOpen(true)}
variant='outline'
@@ -1080,15 +1155,28 @@ export function ModelSelector({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setEditingModel(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
{model.api_key_type !== 'remote' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEditModelClick(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
)}
{model.api_key_type === 'remote' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleOverrideModelClick(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Override
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{model.soft_deleted ? (
<DropdownMenuItem
@@ -1215,23 +1303,16 @@ export function ModelSelector({
})}
{/* Forms and Dialogs */}
<AddModelForm
isOpen={isAddFormOpen}
onModelAdd={handleAddModel}
onCancel={() => setIsAddFormOpen(false)}
/>
{editingModel && (
<EditModelForm
model={editingModel}
providerId={
editingModel.provider_id
? parseInt(editingModel.provider_id)
: undefined
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onModelUpdate={handleModelUpdate}
onCancel={() => setEditingModel(null)}
isOpen={!!editingModel}
onSuccess={handleModelUpdate}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
/>
)}

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

View File

@@ -96,4 +96,3 @@ export function DashboardBalanceSummary({
</div>
);
}

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>

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

View File

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

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

View File

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

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>

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) => (

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>
))}

View File

@@ -64,6 +64,11 @@ class ApiClient {
data,
config
);
console.log(`POST response from ${endpoint}:`, {
status: response.status,
data: response.data,
headers: response.headers,
});
return response.data;
} catch (error) {
this.handleAuthError(error);

View File

@@ -7,6 +7,9 @@ export const ProviderTypeSchema = z.object({
default_base_url: z.string(),
fixed_base_url: z.boolean(),
platform_url: z.string().nullable(),
can_create_account: z.boolean(),
can_topup: z.boolean(),
can_show_balance: z.boolean(),
});
export const UpstreamProviderSchema = z.object({
@@ -64,7 +67,9 @@ export const AdminModelSchema = z.object({
pricing: AdminModelPricingSchema.or(z.record(z.any())),
per_request_limits: z.record(z.any()).nullable().optional(),
top_provider: z.record(z.any()).nullable().optional(),
upstream_provider_id: z.number().nullable().optional(),
upstream_provider_id: z.union([z.string(), z.number()]).nullable().optional(),
canonical_slug: z.string().nullable().optional(),
alias_ids: z.array(z.string()).nullable().optional(),
enabled: z.boolean().default(true),
});
@@ -811,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[] }> {
@@ -856,6 +863,57 @@ export class AdminService {
`/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
);
}
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
message: string;
}> {
return await apiClient.post<{
ok: boolean;
account_data: Record<string, unknown>;
message: string;
}>('/admin/api/upstream-providers/create-account', {
provider_type: providerType,
});
}
static async initiateProviderTopup(
providerId: number,
amount: number
): Promise<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}> {
return await apiClient.post<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}>(`/admin/api/upstream-providers/${providerId}/topup`, {
amount: amount,
});
}
static async checkTopupStatus(
providerId: number,
invoiceId: string
): Promise<{ ok: boolean; paid: boolean }> {
return await apiClient.get<{
ok: boolean;
paid: boolean;
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
}
static async getProviderBalance(providerId: number): Promise<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;
}> {
return await apiClient.get<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;
}>(`/admin/api/upstream-providers/${providerId}/balance`);
}
}
export const TemporaryBalanceSchema = z.object({

View File

@@ -18,4 +18,3 @@ export const useCurrencyStore = create<CurrencyState>()(
}
)
);

279
ui/package-lock.json generated
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",

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
ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
uv.lock generated
View File

@@ -1890,6 +1890,7 @@ dependencies = [
{ name = "marshmallow" },
{ name = "mdurl" },
{ name = "nostr" },
{ name = "openai" },
{ name = "pillow" },
{ name = "python-json-logger" },
{ name = "secp256k1" },
@@ -1923,6 +1924,7 @@ requires-dist = [
{ name = "marshmallow", specifier = ">=3.13,<4.0" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "nostr", specifier = ">=0.0.2" },
{ name = "openai", specifier = ">=1.98.0" },
{ name = "pillow", specifier = ">=10" },
{ name = "python-json-logger", specifier = ">=2.0.0" },
{ name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },