mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
45 Commits
feature/dy
...
remove-fla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
590fb4bc2c | ||
|
|
5db9abc3ce | ||
|
|
c11cc107c8 | ||
|
|
19b5f2889a | ||
|
|
52601f89bd | ||
|
|
72b281b815 | ||
|
|
87b1443c23 | ||
|
|
29129f8953 | ||
|
|
c97c74a2ee | ||
|
|
1e37c42ea0 | ||
|
|
4c7887fa4e | ||
|
|
2cc5063dee | ||
|
|
e4eda59e6a | ||
|
|
7f918eab6a | ||
|
|
b8c34afef8 | ||
|
|
5738b1bd99 | ||
|
|
2aee75e7d5 | ||
|
|
4d67af51ab | ||
|
|
2f841dfcbc | ||
|
|
7ca69063bd | ||
|
|
085ff75d1d | ||
|
|
21340b2de1 | ||
|
|
a150d38df7 | ||
|
|
02ca36141e | ||
|
|
20bf5e35a4 | ||
|
|
5a67e6b4d6 | ||
|
|
34d1e4b041 | ||
|
|
b88ef858ee | ||
|
|
a363c7a0b1 | ||
|
|
4ac257db0d | ||
|
|
fd5ec01a99 | ||
|
|
bc8c08c468 | ||
|
|
d6648d3337 | ||
|
|
e7f677b315 | ||
|
|
d7611e74c3 | ||
|
|
d0790dcb22 | ||
|
|
2b4f71cc4f | ||
|
|
80359d0854 | ||
|
|
a226a78222 | ||
|
|
432fe48f36 | ||
|
|
5eb4a40395 | ||
|
|
95ffc612ca | ||
|
|
afa4a59bb6 | ||
|
|
12515cb0e3 | ||
|
|
0f23335de9 |
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
39
migrations/versions/lightning_invoices_table.py
Normal file
39
migrations/versions/lightning_invoices_table.py
Normal 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")
|
||||
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -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
|
||||
@@ -289,12 +282,8 @@ def create_model_mappings(
|
||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||
|
||||
logger.debug(
|
||||
"Created model mappings",
|
||||
extra={
|
||||
"unique_model_count": len(unique_models),
|
||||
"total_alias_count": len(model_instances),
|
||||
"provider_distribution": provider_counts,
|
||||
},
|
||||
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
||||
extra={"provider_distribution": provider_counts},
|
||||
)
|
||||
|
||||
return model_instances, provider_map, unique_models
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -338,6 +338,11 @@ def setup_logging() -> None:
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"openai": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"httpcore": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
@@ -360,6 +365,11 @@ def setup_logging() -> None:
|
||||
},
|
||||
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
|
||||
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
|
||||
"alembic": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": log_level,
|
||||
|
||||
@@ -54,9 +54,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
# This ensures the database schema is always up-to-date in production
|
||||
# Migrations are idempotent - running them multiple times is safe
|
||||
logger.info("Running database migrations")
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
@@ -104,6 +101,9 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
yield
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
|
||||
@@ -62,7 +62,9 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
logger.debug(f"Found provider announcement: {event['id']}")
|
||||
logger.debug(
|
||||
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
|
||||
)
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
logger.debug("Received EOSE message")
|
||||
|
||||
270
routstr/lightning.py
Normal file
270
routstr/lightning.py
Normal 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()
|
||||
@@ -215,7 +215,7 @@ async def query_nip91_events(
|
||||
continue
|
||||
events_out.append(ev_dict)
|
||||
logger.debug(
|
||||
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
|
||||
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
|
||||
)
|
||||
if drained:
|
||||
last_event_ts = time.time()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.request import urlopen
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -20,14 +17,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 +29,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 +58,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,39 +66,25 @@ class Model(BaseModel):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Fetches model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
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:
|
||||
with urlopen(f"{base_url}/models") as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
prompt = float(pricing.get("prompt", 0))
|
||||
completion = float(pricing.get("completion", 0))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if prompt < 0 or completion < 0:
|
||||
return False
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
if prompt == 0 and completion == 0:
|
||||
return False
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
return True
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
@@ -133,10 +110,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)
|
||||
@@ -155,54 +132,6 @@ def is_openrouter_upstream() -> bool:
|
||||
return base.lower() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def load_models() -> list[Model]:
|
||||
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
|
||||
|
||||
The file path can be specified via the ``MODELS_PATH`` environment variable.
|
||||
If a user-provided models.json exists, it will be used. Otherwise, models are
|
||||
automatically fetched from OpenRouter API in memory. If the example file exists
|
||||
and no user file is provided, it will be used as a fallback.
|
||||
"""
|
||||
|
||||
try:
|
||||
models_path = Path(settings.models_path)
|
||||
except Exception:
|
||||
models_path = Path("models.json")
|
||||
|
||||
# Check if user has actively provided a models.json file
|
||||
if models_path.exists():
|
||||
logger.info(f"Loading models from user-provided file: {models_path}")
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
return [Model(**model) for model in data.get("models", [])] # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
# Fall through to auto-generation
|
||||
|
||||
# Only auto-generate from OpenRouter when upstream is OpenRouter
|
||||
if not is_openrouter_upstream():
|
||||
logger.info(
|
||||
"Skipping auto-generation from OpenRouter because upstream_base_url is not https://openrouter.ai/api/v1"
|
||||
)
|
||||
return []
|
||||
|
||||
logger.info("Auto-generating models from OpenRouter API")
|
||||
try:
|
||||
source_filter = settings.source or None
|
||||
except Exception:
|
||||
source_filter = None
|
||||
source_filter = source_filter if source_filter and source_filter.strip() else None
|
||||
|
||||
models_data = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models_data:
|
||||
logger.error("Failed to fetch models from OpenRouter API")
|
||||
return []
|
||||
|
||||
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
|
||||
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
@@ -431,57 +360,6 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
return model
|
||||
|
||||
|
||||
async def ensure_models_bootstrapped() -> None:
|
||||
async with create_session() as s:
|
||||
existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore
|
||||
if existing:
|
||||
return
|
||||
|
||||
try:
|
||||
models_path = Path(settings.models_path)
|
||||
except Exception:
|
||||
models_path = Path("models.json")
|
||||
|
||||
models_to_insert: list[dict] = []
|
||||
if models_path.exists():
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
models_to_insert = data.get("models", [])
|
||||
logger.info(
|
||||
f"Bootstrapping {len(models_to_insert)} models from {models_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
|
||||
if not models_to_insert and is_openrouter_upstream():
|
||||
logger.info("Bootstrapping models from OpenRouter API")
|
||||
source_filter = None
|
||||
try:
|
||||
src = settings.source or None
|
||||
source_filter = src if src and src.strip() else None
|
||||
except Exception:
|
||||
pass
|
||||
models_to_insert = fetch_openrouter_models(source_filter=source_filter)
|
||||
elif not models_to_insert:
|
||||
logger.info(
|
||||
"No models.json found and upstream is not OpenRouter; skipping bootstrap"
|
||||
)
|
||||
|
||||
for m in models_to_insert:
|
||||
try:
|
||||
model = Model(**m) # type: ignore
|
||||
except Exception:
|
||||
# Some OpenRouter models include extra fields; only map required ones
|
||||
continue
|
||||
exists = await s.get(ModelRow, model.id)
|
||||
if exists:
|
||||
continue
|
||||
payload = _model_to_row_payload(model)
|
||||
s.add(ModelRow(**payload)) # type: ignore
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _update_sats_pricing_once() -> None:
|
||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
||||
from ..proxy import get_upstreams
|
||||
@@ -643,76 +521,6 @@ def _pricing_matches(
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_models_periodically() -> None:
|
||||
"""Background task: periodically fetch OpenRouter models and insert new ones.
|
||||
|
||||
- Respects optional SOURCE filter from settings
|
||||
- Does not overwrite existing rows
|
||||
- Sleeps according to settings.models_refresh_interval_seconds; disabled when 0
|
||||
"""
|
||||
interval = getattr(settings, "models_refresh_interval_seconds", 0)
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
# Only refresh from OpenRouter when upstream is OpenRouter
|
||||
if not is_openrouter_upstream():
|
||||
logger.info("Skipping models refresh: upstream_base_url is not OpenRouter")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
if not settings.enable_models_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
src = settings.source or None
|
||||
source_filter = src if src and src.strip() else None
|
||||
except Exception:
|
||||
source_filter = None
|
||||
|
||||
models = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
async with create_session() as s:
|
||||
result = await s.exec(select(ModelRow.id)) # type: ignore
|
||||
existing_ids = {
|
||||
row[0] if isinstance(row, tuple) else row for row in result.all()
|
||||
}
|
||||
inserted = 0
|
||||
for m in models:
|
||||
try:
|
||||
model = Model(**m) # type: ignore
|
||||
except Exception:
|
||||
continue
|
||||
if model.id in existing_ids:
|
||||
continue
|
||||
payload = _model_to_row_payload(model)
|
||||
try:
|
||||
s.add(ModelRow(**payload)) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
inserted += 1
|
||||
if inserted:
|
||||
await s.commit()
|
||||
logger.info(f"Inserted {inserted} new models from OpenRouter")
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during models refresh",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -15,6 +16,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
FireworksUpstreamProvider,
|
||||
GeminiUpstreamProvider,
|
||||
GenericUpstreamProvider,
|
||||
GroqUpstreamProvider,
|
||||
OllamaUpstreamProvider,
|
||||
|
||||
@@ -1726,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(
|
||||
@@ -1753,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},
|
||||
)
|
||||
|
||||
@@ -1832,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}",
|
||||
@@ -1904,14 +1891,15 @@ class BaseUpstreamProvider:
|
||||
f"Provider {self.provider_type} does not support top-up"
|
||||
)
|
||||
|
||||
async def get_balance(self) -> dict[str, object]:
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Get the current account balance from the provider.
|
||||
|
||||
Returns:
|
||||
Dict with balance information
|
||||
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
|
||||
NotImplementedError: If provider does not support balance checking (default behavior)
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {self.provider_type} does not support balance checking"
|
||||
|
||||
3
routstr/upstream/clients/__init__.py
Normal file
3
routstr/upstream/clients/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .gemini import GeminiClient
|
||||
|
||||
__all__ = ["GeminiClient"]
|
||||
40
routstr/upstream/clients/base.py
Normal file
40
routstr/upstream/clients/base.py
Normal 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
|
||||
88
routstr/upstream/clients/gemini.py
Normal file
88
routstr/upstream/clients/gemini.py
Normal 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
283
routstr/upstream/gemini.py
Normal 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": []}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
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__(
|
||||
@@ -101,11 +102,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
url = f"{self.base_url}/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug(
|
||||
"Fetching models from PPQ.AI",
|
||||
extra={"url": url, "has_api_key": bool(self.api_key)},
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
@@ -113,10 +109,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
data = response.json()
|
||||
|
||||
models_data = data.get("data", [])
|
||||
logger.info(
|
||||
"Fetched models from PPQ.AI",
|
||||
extra={"model_count": len(models_data)},
|
||||
)
|
||||
|
||||
or_models = [
|
||||
Model(**model) # type: ignore
|
||||
@@ -127,11 +119,16 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
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
|
||||
if (model.id == ppqai_model.id)
|
||||
or (model.id.split("/")[-1] == ppqai_model.id)
|
||||
or (model.id == ppqai_model.id.split("/")[-1])
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -192,15 +189,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
|
||||
return models
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
"HTTP error fetching models from PPQ.AI",
|
||||
extra={
|
||||
"status_code": e.response.status_code,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching models from PPQ.AI",
|
||||
@@ -371,16 +359,20 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
|
||||
return topup_data
|
||||
|
||||
async def get_balance(self) -> dict[str, object]:
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Get the current account balance from PPQ.AI.
|
||||
|
||||
Returns:
|
||||
Dict with balance information
|
||||
Float representing the balance amount (in USD), or None if unavailable.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
return await self.check_balance()
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ Integration tests for wallet authentication system including API key generation
|
||||
Tests POST /v1/wallet/topup endpoint and authorization header validation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +14,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -389,69 +387,6 @@ async def test_api_key_with_expiry_time(
|
||||
# The expiry time and refund address functionality is tested elsewhere
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_token_submissions(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test concurrent submissions of different tokens"""
|
||||
|
||||
# Generate multiple unique tokens with known amounts
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
expected_balances = {}
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
# Store expected balance by token hash
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
expected_balances[hashed_key] = amount * 1000 # msats
|
||||
|
||||
# Create concurrent requests
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
assert len(responses) == num_tokens
|
||||
api_keys = set()
|
||||
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
api_key = data["api_key"]
|
||||
api_keys.add(api_key)
|
||||
|
||||
# Verify balance matches the expected amount
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
assert data["balance"] == expected_balances[hashed_key]
|
||||
|
||||
# Should have created unique API keys
|
||||
assert len(api_keys) == num_tokens
|
||||
|
||||
# Verify all keys exist in database
|
||||
for api_key in api_keys:
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
assert db_key.balance == expected_balances[hashed_key]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_with_cashu_token_directly(
|
||||
@@ -504,48 +439,6 @@ async def test_x_cashu_header_support(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_api_key_consistency_under_load(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test API key generation consistency under concurrent load"""
|
||||
|
||||
# Generate a single token
|
||||
token = await testmint_wallet.mint_tokens(1000)
|
||||
|
||||
# First request to create the API key
|
||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||
initial_response = await integration_client.get("/v1/wallet/info")
|
||||
assert initial_response.status_code == 200
|
||||
expected_api_key = initial_response.json()["api_key"]
|
||||
expected_balance = initial_response.json()["balance"]
|
||||
|
||||
# Try to use the same token concurrently multiple times
|
||||
# All should return the same API key since it's already created
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for _ in range(20) # 20 concurrent attempts
|
||||
]
|
||||
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed and return the same API key
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == expected_api_key
|
||||
assert data["balance"] == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_timestamp_accuracy(
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlmodel import select, update
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import ConcurrencyTester, ResponseValidator
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -204,45 +204,6 @@ async def test_expired_api_key_behavior(
|
||||
assert db_key.refund_address == "test@lightning.address"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_access_same_api_key(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test concurrent access with the same API key"""
|
||||
|
||||
# Get the API key from authenticated client
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Create multiple concurrent requests
|
||||
requests = []
|
||||
for i in range(20):
|
||||
# Alternate between both endpoints
|
||||
endpoint = "/v1/wallet/" if i % 2 == 0 else "/v1/wallet/info"
|
||||
requests.append(
|
||||
{
|
||||
"method": "GET",
|
||||
"url": endpoint,
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
)
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed with consistent data
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == api_key
|
||||
assert data["balance"] == initial_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_data_consistency(
|
||||
|
||||
@@ -15,7 +15,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -284,60 +283,6 @@ async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_topups_same_api_key( # type: ignore[no-untyped-def]
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: Any,
|
||||
) -> None:
|
||||
"""Test concurrent top-ups to the same API key"""
|
||||
|
||||
# Get API key
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Generate multiple unique tokens
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
total_amount = 0
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10 # Different amounts
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
total_amount += amount
|
||||
|
||||
# Create concurrent top-up requests
|
||||
requests = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "/v1/wallet/topup",
|
||||
"params": {"cashu_token": token},
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
assert "msats" in response.json()
|
||||
|
||||
# Verify final balance is correct
|
||||
final_response = await authenticated_client.get("/v1/wallet/")
|
||||
final_balance = final_response.json()["balance"]
|
||||
expected_balance = initial_balance + (total_amount * 1000)
|
||||
assert final_balance == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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's API endpoint
|
||||
<strong>Network access:</strong>{' '}
|
||||
Ensure the server can reach the
|
||||
provider'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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
@@ -54,7 +56,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
function ProviderBalance({
|
||||
providerId,
|
||||
platformUrl,
|
||||
}: {
|
||||
providerId: number;
|
||||
platformUrl?: string | null;
|
||||
}) {
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [topupAmount, setTopupAmount] = useState('');
|
||||
const [topupError, setTopupError] = useState('');
|
||||
@@ -68,7 +76,11 @@ function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: balanceData, isLoading, error } = useQuery({
|
||||
const {
|
||||
data: balanceData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
queryFn: () => AdminService.getProviderBalance(providerId),
|
||||
refetchInterval: 30000,
|
||||
@@ -100,7 +112,10 @@ function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
mutationFn: async (amount: number) => {
|
||||
console.log('Calling top-up API with:', { providerId, amount });
|
||||
try {
|
||||
const result = await AdminService.initiateProviderTopup(providerId, amount);
|
||||
const result = await AdminService.initiateProviderTopup(
|
||||
providerId,
|
||||
amount
|
||||
);
|
||||
console.log('API returned:', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
@@ -112,11 +127,8 @@ function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
console.log('Top-up response:', data);
|
||||
console.log('Type of data:', typeof data);
|
||||
console.log('Keys in data:', Object.keys(data || {}));
|
||||
|
||||
if (
|
||||
data?.topup_data?.payment_request &&
|
||||
data?.topup_data?.invoice_id
|
||||
) {
|
||||
|
||||
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
|
||||
setInvoiceData({
|
||||
payment_request: data.topup_data.payment_request as string,
|
||||
invoice_id: data.topup_data.invoice_id as string,
|
||||
@@ -136,6 +148,10 @@ function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
});
|
||||
|
||||
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)) {
|
||||
@@ -151,6 +167,35 @@ function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
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('');
|
||||
@@ -170,12 +215,18 @@ function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
const balance = balanceData.balance_data;
|
||||
let displayValue = 'N/A';
|
||||
|
||||
if (typeof balance.balance === 'number') {
|
||||
displayValue = `$${balance.balance.toFixed(2)}`;
|
||||
} else if (typeof balance.balance === 'string') {
|
||||
displayValue = balance.balance;
|
||||
} else if (balance.amount !== undefined) {
|
||||
displayValue = `$${Number(balance.amount).toFixed(2)}`;
|
||||
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 (
|
||||
@@ -183,7 +234,7 @@ function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsTopupDialogOpen(true)}
|
||||
onClick={handleTopUpClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='w-full font-mono sm:w-auto'
|
||||
@@ -223,9 +274,7 @@ function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
<path d='M5 13l4 4L19 7'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='text-center font-semibold'>
|
||||
Top-up successful!
|
||||
</p>
|
||||
<p className='text-center font-semibold'>Top-up successful!</p>
|
||||
</div>
|
||||
) : invoiceData ? (
|
||||
<div className='flex flex-col items-center gap-4 py-4'>
|
||||
@@ -338,6 +387,17 @@ export default function ProvidersPage() {
|
||||
);
|
||||
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',
|
||||
@@ -428,7 +488,9 @@ export default function ProvidersPage() {
|
||||
...formData,
|
||||
api_key: String(response.account_data.api_key),
|
||||
});
|
||||
toast.success('Account created successfully! API key has been filled in.');
|
||||
toast.success(
|
||||
'Account created successfully! API key has been filled in.'
|
||||
);
|
||||
} else {
|
||||
toast.success('Account created, but no API key returned.');
|
||||
}
|
||||
@@ -533,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' />
|
||||
@@ -697,8 +786,7 @@ export default function ProvidersPage() {
|
||||
)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
1.01 means +1% e.g. currency exchange, card
|
||||
fees, etc.
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -775,7 +863,12 @@ export default function ProvidersPage() {
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{canShowBalance(provider.provider_type) &&
|
||||
provider.api_key && (
|
||||
<ProviderBalance providerId={provider.id} />
|
||||
<ProviderBalance
|
||||
providerId={provider.id}
|
||||
platformUrl={getPlatformUrl(
|
||||
provider.provider_type
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -839,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'>
|
||||
@@ -872,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>
|
||||
))}
|
||||
@@ -925,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'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'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
|
||||
@@ -966,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>
|
||||
)
|
||||
@@ -1003,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>
|
||||
)
|
||||
@@ -1120,7 +1283,7 @@ export default function ProvidersPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Switch
|
||||
<Switch
|
||||
id='edit_enabled'
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -1152,8 +1315,7 @@ export default function ProvidersPage() {
|
||||
)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
1.01 means +1% e.g. currency exchange, card
|
||||
fees, etc.
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1173,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>
|
||||
);
|
||||
|
||||
956
ui/components/AddProviderModelDialog.tsx
Normal file
956
ui/components/AddProviderModelDialog.tsx
Normal 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->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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -48,20 +48,26 @@ export function CurrencyToggle() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9 w-9 px-0 gap-2 w-auto px-3 font-normal">
|
||||
<Coins className="h-4 w-4" />
|
||||
<span className="hidden sm:inline-block">{getLabel(displayUnit)}</span>
|
||||
<span className="sm:hidden uppercase">{displayUnit}</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
|
||||
>
|
||||
<Coins className='h-4 w-4' />
|
||||
<span className='hidden sm:inline-block'>
|
||||
{getLabel(displayUnit)}
|
||||
</span>
|
||||
<span className='uppercase sm:hidden'>{displayUnit}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
|
||||
Millisatoshis (mSAT)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
|
||||
Satoshis (sat)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayUnit('usd')}
|
||||
disabled={!usdPerSat}
|
||||
>
|
||||
@@ -71,4 +77,3 @@ export function CurrencyToggle() {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,4 +96,3 @@ export function DashboardBalanceSummary({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
|
||||
<CardTitle>Recent Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground text-center py-8'>
|
||||
<p className='text-muted-foreground py-8 text-center'>
|
||||
No errors found in the selected time period
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
284
ui/components/landing/api-key-manager.tsx
Normal file
284
ui/components/landing/api-key-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
452
ui/components/landing/cashu-payment-workflow.tsx
Normal file
452
ui/components/landing/cashu-payment-workflow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
685
ui/components/landing/lightning-payment-workflow.tsx
Normal file
685
ui/components/landing/lightning-payment-workflow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -67,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),
|
||||
});
|
||||
|
||||
@@ -814,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[] }> {
|
||||
@@ -860,9 +864,7 @@ export class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
static async createProviderAccountByType(
|
||||
providerType: string
|
||||
): Promise<{
|
||||
static async createProviderAccountByType(providerType: string): Promise<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
message: string;
|
||||
@@ -879,7 +881,11 @@ export class AdminService {
|
||||
static async initiateProviderTopup(
|
||||
providerId: number,
|
||||
amount: number
|
||||
): Promise<{ ok: boolean; topup_data: Record<string, unknown>; message: string }> {
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
@@ -899,12 +905,13 @@ export class AdminService {
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
|
||||
}
|
||||
|
||||
static async getProviderBalance(
|
||||
providerId: number
|
||||
): Promise<{ ok: boolean; balance_data: Record<string, unknown> }> {
|
||||
static async getProviderBalance(providerId: number): Promise<{
|
||||
ok: boolean;
|
||||
balance_data: number | null | Record<string, unknown>;
|
||||
}> {
|
||||
return await apiClient.get<{
|
||||
ok: boolean;
|
||||
balance_data: Record<string, unknown>;
|
||||
balance_data: number | null | Record<string, unknown>;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/balance`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,3 @@ export const useCurrencyStore = create<CurrencyState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
279
ui/package-lock.json
generated
279
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
7998
ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
uv.lock
generated
2
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user