mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
1769 lines
61 KiB
Python
1769 lines
61 KiB
Python
import asyncio
|
|
import json
|
|
import re
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel, RootModel
|
|
from pydantic.v1 import ValidationError as PydanticValidationError
|
|
from sqlmodel import select
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from ..payment.models import _row_to_model, list_models
|
|
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
|
from ..wallet import (
|
|
fetch_all_balances,
|
|
get_proofs_per_mint_and_unit,
|
|
get_wallet,
|
|
send_token,
|
|
slow_filter_spend_proofs,
|
|
)
|
|
from .db import (
|
|
ApiKey,
|
|
CashuTransaction,
|
|
CliToken,
|
|
LightningInvoice,
|
|
ModelRow,
|
|
UpstreamProviderRow,
|
|
create_session,
|
|
)
|
|
from .db import (
|
|
store_cashu_transaction_with_retry as store_cashu_transaction,
|
|
)
|
|
from .log_manager import log_manager
|
|
from .logging import get_logger
|
|
from .provider_slugs import allocate_unique_provider_slug
|
|
from .settings import SettingsService, settings
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
|
|
|
|
admin_sessions: dict[str, int] = {}
|
|
ADMIN_SESSION_DURATION = 3600
|
|
# Usage analytics remain queryable up to 12 months.
|
|
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
|
|
|
|
|
|
async def require_admin_api(request: Request) -> None:
|
|
auth_header = request.headers.get("Authorization")
|
|
if not auth_header or not auth_header.startswith("Bearer "):
|
|
raise HTTPException(status_code=403, detail="Unauthorized")
|
|
|
|
token = auth_header.split(" ", 1)[1]
|
|
now_ts = int(datetime.now(timezone.utc).timestamp())
|
|
|
|
# 1) Short-lived session token (in-memory)
|
|
expiry = admin_sessions.get(token)
|
|
if expiry and expiry > now_ts:
|
|
return
|
|
|
|
# 2) Long-lived CLI token (DB-backed)
|
|
async with create_session() as session:
|
|
result = await session.exec(select(CliToken).where(CliToken.token == token))
|
|
cli_token = result.first()
|
|
if cli_token and (cli_token.expires_at is None or cli_token.expires_at > now_ts):
|
|
cli_token.last_used_at = now_ts
|
|
session.add(cli_token)
|
|
await session.commit()
|
|
return
|
|
|
|
raise HTTPException(status_code=403, detail="Unauthorized")
|
|
|
|
|
|
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
|
|
async def get_temporary_balances_api(
|
|
request: Request,
|
|
search: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> dict[str, object]:
|
|
from sqlalchemy import case
|
|
from sqlmodel import col, func
|
|
|
|
filters = []
|
|
if search:
|
|
pattern = f"%{search}%"
|
|
filters.append(
|
|
col(ApiKey.hashed_key).like(pattern)
|
|
| col(ApiKey.refund_address).like(pattern)
|
|
)
|
|
|
|
async with create_session() as session:
|
|
base = select(ApiKey).where(*filters)
|
|
|
|
count_result = await session.exec(
|
|
select(func.count()).select_from(base.subquery())
|
|
)
|
|
total = count_result.one()
|
|
|
|
# Aggregate totals across the whole (search-filtered) set, not just the
|
|
# current page. Balance counts only parent (non-child) keys to avoid
|
|
# double-counting, since child keys draw from their parent's balance.
|
|
totals_result = await session.exec(
|
|
select(
|
|
func.coalesce(
|
|
func.sum(
|
|
case(
|
|
(col(ApiKey.parent_key_hash).is_(None), ApiKey.balance),
|
|
else_=0,
|
|
)
|
|
),
|
|
0,
|
|
),
|
|
func.coalesce(func.sum(ApiKey.total_spent), 0),
|
|
func.coalesce(func.sum(ApiKey.total_requests), 0),
|
|
).where(*filters)
|
|
)
|
|
total_balance, total_spent, total_requests = totals_result.one()
|
|
|
|
# Latest created first; keys with no created_at (legacy rows) sort last.
|
|
# Use an explicit CASE rather than relying on dialect NULL-ordering so
|
|
# the behaviour is identical on SQLite and Postgres.
|
|
stmt = (
|
|
base.order_by(
|
|
case((col(ApiKey.created_at).is_(None), 1), else_=0),
|
|
col(ApiKey.created_at).desc(),
|
|
)
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
result = await session.exec(stmt)
|
|
api_keys = result.all()
|
|
|
|
return {
|
|
"balances": [
|
|
{
|
|
"hashed_key": key.hashed_key,
|
|
"balance": key.balance,
|
|
"total_spent": key.total_spent,
|
|
"total_requests": key.total_requests,
|
|
"refund_address": key.refund_address,
|
|
"key_expiry_time": key.key_expiry_time,
|
|
"parent_key_hash": key.parent_key_hash,
|
|
"balance_limit": key.balance_limit,
|
|
"balance_limit_reset": key.balance_limit_reset,
|
|
"validity_date": key.validity_date,
|
|
"created_at": key.created_at,
|
|
}
|
|
for key in api_keys
|
|
],
|
|
"total": total,
|
|
"totals": {
|
|
"total_balance": total_balance,
|
|
"total_spent": total_spent,
|
|
"total_requests": total_requests,
|
|
},
|
|
}
|
|
|
|
|
|
class ApiKeyUpdate(BaseModel):
|
|
balance_limit: int | None = None
|
|
balance_limit_reset: str | None = None
|
|
validity_date: int | None = None
|
|
|
|
|
|
@admin_router.patch(
|
|
"/api/apikeys/{hashed_key}", dependencies=[Depends(require_admin_api)]
|
|
)
|
|
async def update_apikey(
|
|
request: Request, hashed_key: str, update: ApiKeyUpdate
|
|
) -> dict:
|
|
async with create_session() as session:
|
|
key = await session.get(ApiKey, hashed_key)
|
|
if not key:
|
|
raise HTTPException(status_code=404, detail="API key not found")
|
|
|
|
if update.balance_limit is not None:
|
|
key.balance_limit = update.balance_limit
|
|
if update.balance_limit_reset is not None:
|
|
key.balance_limit_reset = update.balance_limit_reset
|
|
if update.validity_date is not None:
|
|
key.validity_date = update.validity_date
|
|
|
|
session.add(key)
|
|
await session.commit()
|
|
await session.refresh(key)
|
|
|
|
return {
|
|
"hashed_key": key.hashed_key,
|
|
"balance_limit": key.balance_limit,
|
|
"balance_limit_reset": key.balance_limit_reset,
|
|
"validity_date": key.validity_date,
|
|
}
|
|
|
|
|
|
@admin_router.get("/api/balances", dependencies=[Depends(require_admin_api)])
|
|
async def get_balances_api(request: Request) -> list[dict[str, object]]:
|
|
balance_details, _tw, _tu, _ow = await fetch_all_balances()
|
|
return [dict(d) for d in balance_details]
|
|
|
|
|
|
@admin_router.get("/api/settings", dependencies=[Depends(require_admin_api)])
|
|
async def get_settings(request: Request) -> dict:
|
|
data = settings.dict()
|
|
if "upstream_api_key" in data:
|
|
data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else ""
|
|
if "admin_password" in data:
|
|
data["admin_password"] = "[REDACTED]" if data["admin_password"] else ""
|
|
if "nsec" in data:
|
|
data["nsec"] = "[REDACTED]" if data["nsec"] else ""
|
|
return data
|
|
|
|
|
|
class SettingsUpdate(RootModel[dict[str, object]]):
|
|
pass
|
|
|
|
|
|
class PasswordUpdate(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
@admin_router.patch("/api/settings", dependencies=[Depends(require_admin_api)])
|
|
async def update_settings(request: Request, update: SettingsUpdate) -> dict:
|
|
# Remove sensitive fields from general settings update
|
|
settings_data = update.root.copy()
|
|
sensitive_fields = ["admin_password", "upstream_api_key", "nsec"]
|
|
for field in sensitive_fields:
|
|
if field in settings_data:
|
|
del settings_data[field]
|
|
|
|
try:
|
|
async with create_session() as session:
|
|
new_settings = await SettingsService.update(settings_data, session)
|
|
except PydanticValidationError as e:
|
|
# Surface validation issues (e.g. non-positive payout amounts)
|
|
# as a clean 400 instead of a 500.
|
|
raise HTTPException(status_code=400, detail=e.errors()) from e
|
|
data = new_settings.dict()
|
|
if "upstream_api_key" in data:
|
|
data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else ""
|
|
if "admin_password" in data:
|
|
data["admin_password"] = "[REDACTED]" if data["admin_password"] else ""
|
|
if "nsec" in data:
|
|
data["nsec"] = "[REDACTED]" if data["nsec"] else ""
|
|
return data
|
|
|
|
|
|
@admin_router.patch("/api/password", dependencies=[Depends(require_admin_api)])
|
|
async def update_password(request: Request, password_update: PasswordUpdate) -> dict:
|
|
current_password = settings.admin_password
|
|
|
|
if not current_password:
|
|
raise HTTPException(status_code=500, detail="Admin password not configured")
|
|
|
|
if password_update.current_password != current_password:
|
|
raise HTTPException(status_code=401, detail="Current password is incorrect")
|
|
|
|
# Validate new password
|
|
new_password = password_update.new_password.strip()
|
|
if len(new_password) < 6:
|
|
raise HTTPException(
|
|
status_code=400, detail="New password must be at least 6 characters"
|
|
)
|
|
|
|
# Update password
|
|
async with create_session() as session:
|
|
await SettingsService.update({"admin_password": new_password}, session)
|
|
|
|
return {"ok": True, "message": "Password updated successfully"}
|
|
|
|
|
|
class SetupRequest(BaseModel):
|
|
password: str
|
|
|
|
|
|
@admin_router.post("/api/setup")
|
|
async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]:
|
|
if settings.admin_password:
|
|
raise HTTPException(status_code=409, detail="Admin password already set")
|
|
pw = (payload.password or "").strip()
|
|
if len(pw) < 8:
|
|
raise HTTPException(
|
|
status_code=400, detail="Password must be at least 8 characters"
|
|
)
|
|
async with create_session() as session:
|
|
await SettingsService.update({"admin_password": pw}, session)
|
|
return {"ok": True}
|
|
|
|
|
|
class AdminLoginRequest(BaseModel):
|
|
password: str
|
|
|
|
|
|
@admin_router.post("/api/login")
|
|
async def admin_login(
|
|
request: Request, payload: AdminLoginRequest
|
|
) -> dict[str, object]:
|
|
admin_pw = settings.admin_password
|
|
|
|
if not admin_pw:
|
|
raise HTTPException(status_code=500, detail="Admin password not configured")
|
|
|
|
if payload.password != admin_pw:
|
|
raise HTTPException(status_code=401, detail="Invalid password")
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
expiry_timestamp = (
|
|
int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION
|
|
)
|
|
admin_sessions[token] = expiry_timestamp
|
|
|
|
expired_tokens = [
|
|
t
|
|
for t, exp in admin_sessions.items()
|
|
if exp <= int(datetime.now(timezone.utc).timestamp())
|
|
]
|
|
for t in expired_tokens:
|
|
del admin_sessions[t]
|
|
|
|
return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION}
|
|
|
|
|
|
@admin_router.post("/api/logout", dependencies=[Depends(require_admin_api)])
|
|
async def admin_logout(request: Request) -> dict[str, object]:
|
|
auth_header = request.headers.get("Authorization")
|
|
if auth_header and auth_header.startswith("Bearer "):
|
|
token = auth_header.split(" ", 1)[1]
|
|
if token in admin_sessions:
|
|
del admin_sessions[token]
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
# ─── CLI Tokens (long-lived bearer tokens for CLI/agent use) ───
|
|
|
|
|
|
class CliTokenCreate(BaseModel):
|
|
name: str
|
|
expires_in_days: int | None = None
|
|
|
|
|
|
@admin_router.get("/api/cli-tokens", dependencies=[Depends(require_admin_api)])
|
|
async def list_cli_tokens() -> list[dict[str, object]]:
|
|
async with create_session() as session:
|
|
result = await session.exec(select(CliToken))
|
|
tokens = result.all()
|
|
return [
|
|
{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"token_preview": f"{t.token[:8]}...{t.token[-4:]}",
|
|
"created_at": t.created_at,
|
|
"last_used_at": t.last_used_at,
|
|
"expires_at": t.expires_at,
|
|
}
|
|
for t in tokens
|
|
]
|
|
|
|
|
|
@admin_router.post("/api/cli-tokens", dependencies=[Depends(require_admin_api)])
|
|
async def create_cli_token(payload: CliTokenCreate) -> dict[str, object]:
|
|
name = (payload.name or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Name is required")
|
|
|
|
raw_token = secrets.token_urlsafe(32)
|
|
expires_at: int | None = None
|
|
if payload.expires_in_days is not None and payload.expires_in_days > 0:
|
|
expires_at = int(datetime.now(timezone.utc).timestamp()) + (
|
|
payload.expires_in_days * 86400
|
|
)
|
|
|
|
async with create_session() as session:
|
|
cli_token = CliToken(token=raw_token, name=name, expires_at=expires_at)
|
|
session.add(cli_token)
|
|
await session.commit()
|
|
await session.refresh(cli_token)
|
|
|
|
return {
|
|
"id": cli_token.id,
|
|
"name": cli_token.name,
|
|
"token": raw_token, # full token returned only on creation
|
|
"created_at": cli_token.created_at,
|
|
"expires_at": cli_token.expires_at,
|
|
}
|
|
|
|
|
|
@admin_router.delete(
|
|
"/api/cli-tokens/{token_id}", dependencies=[Depends(require_admin_api)]
|
|
)
|
|
async def revoke_cli_token(token_id: str) -> dict[str, object]:
|
|
async with create_session() as session:
|
|
cli_token = await session.get(CliToken, token_id)
|
|
if not cli_token:
|
|
raise HTTPException(status_code=404, detail="Token not found")
|
|
await session.delete(cli_token)
|
|
await session.commit()
|
|
return {"ok": True, "deleted_id": token_id}
|
|
|
|
|
|
class WithdrawRequest(BaseModel):
|
|
amount: int
|
|
mint_url: str | None = None
|
|
unit: str = "sat"
|
|
|
|
|
|
@admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)])
|
|
async def withdraw(
|
|
request: Request, withdraw_request: WithdrawRequest
|
|
) -> dict[str, str]:
|
|
# Get wallet and check balance
|
|
from .settings import settings as global_settings
|
|
|
|
effective_mint = withdraw_request.mint_url or global_settings.primary_mint
|
|
wallet = await get_wallet(effective_mint, withdraw_request.unit)
|
|
proofs = get_proofs_per_mint_and_unit(
|
|
wallet,
|
|
effective_mint,
|
|
withdraw_request.unit,
|
|
not_reserved=True,
|
|
)
|
|
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
|
current_balance = sum(proof.amount for proof in proofs)
|
|
|
|
if withdraw_request.amount <= 0:
|
|
raise HTTPException(
|
|
status_code=400, detail="Withdrawal amount must be positive"
|
|
)
|
|
|
|
if withdraw_request.amount > current_balance:
|
|
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
|
|
|
|
token = await send_token(
|
|
withdraw_request.amount, withdraw_request.unit, effective_mint
|
|
)
|
|
try:
|
|
await store_cashu_transaction(
|
|
token=token,
|
|
amount=withdraw_request.amount,
|
|
unit=withdraw_request.unit,
|
|
mint_url=effective_mint,
|
|
typ="out",
|
|
collected=False,
|
|
source="admin",
|
|
)
|
|
except Exception:
|
|
logger.critical(
|
|
"Admin withdrawal token issued without a persisted audit record",
|
|
extra={
|
|
"amount": withdraw_request.amount,
|
|
"unit": withdraw_request.unit,
|
|
"mint_url": effective_mint,
|
|
},
|
|
)
|
|
return {"token": token}
|
|
|
|
|
|
class ModelCreate(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: str
|
|
created: int
|
|
context_length: int
|
|
architecture: dict[str, object]
|
|
pricing: dict[str, object]
|
|
per_request_limits: dict[str, object] | None = None
|
|
top_provider: dict[str, object] | None = None
|
|
upstream_provider_id: int | None = None
|
|
canonical_slug: str | None = None
|
|
alias_ids: list[str] | None = None
|
|
enabled: bool = True
|
|
forwarded_model_id: str | None = None
|
|
|
|
|
|
@admin_router.post(
|
|
"/api/upstream-providers/{provider_id}/models",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def upsert_provider_model(
|
|
provider_id: str, payload: ModelCreate
|
|
) -> dict[str, object]:
|
|
print(payload)
|
|
logger.info(
|
|
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
|
)
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
provider_pk = _provider_pk(provider)
|
|
|
|
# Try to get existing model
|
|
existing_row = await session.get(ModelRow, (payload.id, provider_pk))
|
|
|
|
if existing_row:
|
|
# Update existing model
|
|
logger.info(f"Updating existing model: {payload.id}")
|
|
existing_row.name = payload.name
|
|
existing_row.description = payload.description
|
|
existing_row.created = int(payload.created)
|
|
existing_row.context_length = int(payload.context_length)
|
|
existing_row.architecture = json.dumps(payload.architecture)
|
|
existing_row.pricing = json.dumps(payload.pricing)
|
|
existing_row.sats_pricing = None
|
|
existing_row.per_request_limits = (
|
|
json.dumps(payload.per_request_limits)
|
|
if payload.per_request_limits is not None
|
|
else None
|
|
)
|
|
existing_row.top_provider = (
|
|
json.dumps(payload.top_provider) if payload.top_provider else None
|
|
)
|
|
existing_row.canonical_slug = payload.canonical_slug
|
|
existing_row.alias_ids = (
|
|
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
|
)
|
|
existing_row.enabled = payload.enabled
|
|
existing_row.forwarded_model_id = payload.forwarded_model_id or payload.id
|
|
|
|
session.add(existing_row)
|
|
await session.commit()
|
|
await session.refresh(existing_row)
|
|
row = existing_row
|
|
|
|
else:
|
|
# Create new model
|
|
logger.info(f"Creating new model: {payload.id}")
|
|
row = ModelRow(
|
|
id=payload.id,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
created=int(payload.created),
|
|
context_length=int(payload.context_length),
|
|
architecture=json.dumps(payload.architecture),
|
|
pricing=json.dumps(payload.pricing),
|
|
sats_pricing=None,
|
|
per_request_limits=(
|
|
json.dumps(payload.per_request_limits)
|
|
if payload.per_request_limits is not None
|
|
else None
|
|
),
|
|
top_provider=(
|
|
json.dumps(payload.top_provider) if payload.top_provider else None
|
|
),
|
|
canonical_slug=payload.canonical_slug,
|
|
alias_ids=(
|
|
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
|
),
|
|
upstream_provider_id=provider_pk,
|
|
enabled=payload.enabled,
|
|
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
|
)
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
|
|
await refresh_model_maps()
|
|
return _row_to_model(
|
|
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
|
).dict() # type: ignore
|
|
|
|
|
|
@admin_router.patch(
|
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def update_provider_model_legacy(
|
|
provider_id: str, model_id: str, payload: ModelCreate
|
|
) -> dict[str, object]:
|
|
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
|
logger.info(
|
|
f"LEGACY_PATCH_UPDATE called: provider_id={provider_id}, model_id={model_id}"
|
|
)
|
|
return await upsert_provider_model(provider_id, payload)
|
|
|
|
|
|
@admin_router.get(
|
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def get_provider_model(provider_id: str, model_id: str) -> dict[str, object]:
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
provider_pk = _provider_pk(provider)
|
|
|
|
row = await session.get(ModelRow, (model_id, provider_pk))
|
|
if not row:
|
|
raise HTTPException(
|
|
status_code=404, detail="Model not found for this provider"
|
|
)
|
|
return _row_to_model(
|
|
row, apply_provider_fee=False, provider_fee=provider.provider_fee
|
|
).dict() # type: ignore
|
|
|
|
|
|
@admin_router.delete(
|
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def delete_provider_model(provider_id: str, model_id: str) -> dict[str, object]:
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
provider_pk = _provider_pk(provider)
|
|
row = await session.get(ModelRow, (model_id, provider_pk))
|
|
if not row:
|
|
raise HTTPException(
|
|
status_code=404, detail="Model not found for this provider"
|
|
)
|
|
await session.delete(row)
|
|
await session.commit()
|
|
await refresh_model_maps()
|
|
return {"ok": True, "deleted_id": model_id}
|
|
|
|
|
|
@admin_router.delete(
|
|
"/api/upstream-providers/{provider_id}/models",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def delete_all_provider_models(provider_id: str) -> dict[str, object]:
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
provider_pk = _provider_pk(provider)
|
|
result = await session.exec(
|
|
select(ModelRow).where(ModelRow.upstream_provider_id == provider_pk)
|
|
) # type: ignore
|
|
rows = result.all()
|
|
for row in rows:
|
|
await session.delete(row) # type: ignore
|
|
await session.commit()
|
|
await refresh_model_maps()
|
|
return {"ok": True, "deleted": len(rows)}
|
|
|
|
|
|
class BatchOverrideRequest(BaseModel):
|
|
models: list[ModelCreate]
|
|
|
|
|
|
@admin_router.post(
|
|
"/api/upstream-providers/{provider_id}/batch-override",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def batch_override_provider_models(
|
|
provider_id: str, payload: BatchOverrideRequest
|
|
) -> dict[str, object]:
|
|
"""Batch override models for a specific provider."""
|
|
logger.info(
|
|
f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}"
|
|
)
|
|
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
provider_pk = _provider_pk(provider)
|
|
|
|
overridden_count = 0
|
|
|
|
for model_data in payload.models:
|
|
# Try to get existing model regardless of whether it's enabled or not
|
|
existing_row = await session.get(ModelRow, (model_data.id, provider_pk))
|
|
|
|
if existing_row:
|
|
# Update existing
|
|
existing_row.name = model_data.name
|
|
existing_row.description = model_data.description
|
|
existing_row.created = int(model_data.created)
|
|
existing_row.context_length = int(model_data.context_length)
|
|
existing_row.architecture = json.dumps(model_data.architecture)
|
|
existing_row.pricing = json.dumps(model_data.pricing)
|
|
existing_row.sats_pricing = None
|
|
existing_row.per_request_limits = (
|
|
json.dumps(model_data.per_request_limits)
|
|
if model_data.per_request_limits is not None
|
|
else None
|
|
)
|
|
existing_row.top_provider = (
|
|
json.dumps(model_data.top_provider)
|
|
if model_data.top_provider
|
|
else None
|
|
)
|
|
existing_row.canonical_slug = model_data.canonical_slug
|
|
existing_row.alias_ids = (
|
|
json.dumps(model_data.alias_ids) if model_data.alias_ids else None
|
|
)
|
|
existing_row.enabled = model_data.enabled
|
|
session.add(existing_row)
|
|
else:
|
|
# Create new
|
|
row = ModelRow(
|
|
id=model_data.id,
|
|
name=model_data.name,
|
|
description=model_data.description,
|
|
created=int(model_data.created),
|
|
context_length=int(model_data.context_length),
|
|
architecture=json.dumps(model_data.architecture),
|
|
pricing=json.dumps(model_data.pricing),
|
|
sats_pricing=None,
|
|
per_request_limits=(
|
|
json.dumps(model_data.per_request_limits)
|
|
if model_data.per_request_limits is not None
|
|
else None
|
|
),
|
|
top_provider=(
|
|
json.dumps(model_data.top_provider)
|
|
if model_data.top_provider
|
|
else None
|
|
),
|
|
canonical_slug=model_data.canonical_slug,
|
|
alias_ids=(
|
|
json.dumps(model_data.alias_ids)
|
|
if model_data.alias_ids
|
|
else None
|
|
),
|
|
upstream_provider_id=provider_pk,
|
|
enabled=model_data.enabled,
|
|
)
|
|
session.add(row)
|
|
|
|
overridden_count += 1
|
|
|
|
await session.commit()
|
|
|
|
await refresh_model_maps()
|
|
return {
|
|
"ok": True,
|
|
"count": overridden_count,
|
|
"message": f"Successfully batch overridden {overridden_count} models",
|
|
}
|
|
|
|
|
|
_SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$")
|
|
|
|
|
|
def _validate_slug(value: str) -> str:
|
|
candidate = value.strip().lower()
|
|
if not _SLUG_PATTERN.fullmatch(candidate):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
"slug must be 3-64 chars, lowercase letters/digits/hyphens, "
|
|
"and may not start or end with a hyphen"
|
|
),
|
|
)
|
|
if candidate.isdigit():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="slug must not be all digits",
|
|
)
|
|
return candidate
|
|
|
|
|
|
async def _ensure_unique_slug(
|
|
session: AsyncSession, slug: str, exclude_id: int | None = None
|
|
) -> None:
|
|
stmt = select(UpstreamProviderRow).where(UpstreamProviderRow.slug == slug)
|
|
result = await session.exec(stmt)
|
|
existing = result.first()
|
|
if existing and existing.id != exclude_id:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Provider with this slug already exists",
|
|
)
|
|
|
|
|
|
async def _get_upstream_provider_by_ref(
|
|
session: AsyncSession, provider_ref: str
|
|
) -> UpstreamProviderRow:
|
|
if provider_ref.isdigit():
|
|
provider = await session.get(UpstreamProviderRow, int(provider_ref))
|
|
else:
|
|
slug = _validate_slug(provider_ref)
|
|
result = await session.exec(
|
|
select(UpstreamProviderRow).where(UpstreamProviderRow.slug == slug)
|
|
)
|
|
provider = result.first()
|
|
|
|
if not provider:
|
|
raise HTTPException(status_code=404, detail="Provider not found")
|
|
return provider
|
|
|
|
|
|
def _provider_pk(provider: UpstreamProviderRow) -> int:
|
|
if provider.id is None:
|
|
raise HTTPException(status_code=500, detail="Provider has no database id")
|
|
return provider.id
|
|
|
|
|
|
def _serialize_provider(
|
|
provider: UpstreamProviderRow, redact_api_key: bool = True
|
|
) -> dict[str, object]:
|
|
return {
|
|
"id": provider.id,
|
|
"slug": provider.slug,
|
|
"provider_type": provider.provider_type,
|
|
"base_url": provider.base_url,
|
|
"api_key": "[REDACTED]"
|
|
if (redact_api_key and provider.api_key)
|
|
else provider.api_key
|
|
if not redact_api_key
|
|
else "",
|
|
"api_version": provider.api_version,
|
|
"enabled": provider.enabled,
|
|
"provider_fee": provider.provider_fee,
|
|
"provider_settings": json.loads(provider.provider_settings)
|
|
if provider.provider_settings
|
|
else None,
|
|
}
|
|
|
|
|
|
class UpstreamProviderCreate(BaseModel):
|
|
provider_type: str
|
|
base_url: str
|
|
api_key: str
|
|
api_version: str | None = None
|
|
enabled: bool = True
|
|
provider_fee: float = 1.01
|
|
provider_settings: dict | None = None
|
|
slug: str | None = None
|
|
|
|
|
|
class UpstreamProviderUpdate(BaseModel):
|
|
provider_type: str | None = None
|
|
base_url: str | None = None
|
|
api_key: str | None = None
|
|
api_version: str | None = None
|
|
enabled: bool | None = None
|
|
provider_fee: float | None = None
|
|
provider_settings: dict | None = None
|
|
slug: str | None = None
|
|
|
|
|
|
class UpstreamProviderUpdateBySlug(BaseModel):
|
|
slug: str
|
|
new_slug: str | None = None
|
|
provider_type: str | None = None
|
|
base_url: str | None = None
|
|
api_key: str | None = None
|
|
api_version: str | None = None
|
|
enabled: bool | None = None
|
|
provider_fee: float | None = None
|
|
provider_settings: dict | None = None
|
|
|
|
|
|
async def _apply_provider_update(
|
|
session: AsyncSession,
|
|
provider: UpstreamProviderRow,
|
|
payload: UpstreamProviderUpdate,
|
|
new_slug: str | None = None,
|
|
) -> None:
|
|
if new_slug is not None:
|
|
validated = _validate_slug(new_slug)
|
|
await _ensure_unique_slug(session, validated, exclude_id=provider.id)
|
|
provider.slug = validated
|
|
|
|
if payload.provider_type is not None:
|
|
provider.provider_type = payload.provider_type
|
|
if payload.base_url is not None:
|
|
provider.base_url = payload.base_url
|
|
if payload.api_key is not None:
|
|
provider.api_key = payload.api_key
|
|
if payload.api_version is not None:
|
|
provider.api_version = payload.api_version
|
|
if payload.enabled is not None:
|
|
provider.enabled = payload.enabled
|
|
if payload.provider_fee is not None:
|
|
provider.provider_fee = payload.provider_fee
|
|
if payload.provider_settings is not None:
|
|
provider.provider_settings = json.dumps(payload.provider_settings)
|
|
|
|
session.add(provider)
|
|
await session.commit()
|
|
await session.refresh(provider)
|
|
|
|
|
|
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
|
async def get_upstream_providers() -> list[dict[str, object]]:
|
|
async with create_session() as session:
|
|
result = await session.exec(select(UpstreamProviderRow))
|
|
providers = result.all()
|
|
return [_serialize_provider(p) for p in providers]
|
|
|
|
|
|
@admin_router.post("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
|
async def create_upstream_provider(
|
|
payload: UpstreamProviderCreate,
|
|
) -> dict[str, object]:
|
|
async with create_session() as session:
|
|
result = await session.exec(
|
|
select(UpstreamProviderRow).where(
|
|
UpstreamProviderRow.base_url == payload.base_url,
|
|
UpstreamProviderRow.api_key == payload.api_key,
|
|
)
|
|
)
|
|
if result.first():
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Provider with this base URL and API key already exists",
|
|
)
|
|
|
|
if payload.slug:
|
|
slug = _validate_slug(payload.slug)
|
|
await _ensure_unique_slug(session, slug)
|
|
else:
|
|
slug = await allocate_unique_provider_slug(session, payload.provider_type)
|
|
|
|
provider = UpstreamProviderRow(
|
|
slug=slug,
|
|
provider_type=payload.provider_type,
|
|
base_url=payload.base_url,
|
|
api_key=payload.api_key,
|
|
api_version=payload.api_version,
|
|
enabled=payload.enabled,
|
|
provider_fee=payload.provider_fee,
|
|
provider_settings=json.dumps(payload.provider_settings)
|
|
if payload.provider_settings
|
|
else None,
|
|
)
|
|
session.add(provider)
|
|
await session.commit()
|
|
await session.refresh(provider)
|
|
|
|
await reinitialize_upstreams()
|
|
await refresh_model_maps()
|
|
return _serialize_provider(provider)
|
|
|
|
|
|
@admin_router.get(
|
|
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
|
)
|
|
async def get_upstream_provider(provider_id: str) -> dict[str, object]:
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
return _serialize_provider(provider)
|
|
|
|
|
|
@admin_router.patch(
|
|
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
|
)
|
|
async def update_upstream_provider(
|
|
provider_id: str, payload: UpstreamProviderUpdate
|
|
) -> dict[str, object]:
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
|
|
await _apply_provider_update(session, provider, payload, new_slug=payload.slug)
|
|
|
|
await reinitialize_upstreams()
|
|
await refresh_model_maps()
|
|
return _serialize_provider(provider)
|
|
|
|
|
|
@admin_router.patch(
|
|
"/api/upstream-providers", dependencies=[Depends(require_admin_api)]
|
|
)
|
|
async def update_upstream_provider_by_slug(
|
|
payload: UpstreamProviderUpdateBySlug,
|
|
) -> dict[str, object]:
|
|
lookup = _validate_slug(payload.slug)
|
|
async with create_session() as session:
|
|
result = await session.exec(
|
|
select(UpstreamProviderRow).where(
|
|
UpstreamProviderRow.slug == lookup
|
|
)
|
|
)
|
|
provider = result.first()
|
|
if not provider:
|
|
raise HTTPException(status_code=404, detail="Provider not found")
|
|
|
|
update_payload = UpstreamProviderUpdate(
|
|
provider_type=payload.provider_type,
|
|
base_url=payload.base_url,
|
|
api_key=payload.api_key,
|
|
api_version=payload.api_version,
|
|
enabled=payload.enabled,
|
|
provider_fee=payload.provider_fee,
|
|
provider_settings=payload.provider_settings,
|
|
)
|
|
await _apply_provider_update(
|
|
session, provider, update_payload, new_slug=payload.new_slug
|
|
)
|
|
|
|
await reinitialize_upstreams()
|
|
await refresh_model_maps()
|
|
return _serialize_provider(provider)
|
|
|
|
|
|
@admin_router.delete(
|
|
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
|
)
|
|
async def delete_upstream_provider(provider_id: str) -> dict[str, object]:
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
deleted_id = _provider_pk(provider)
|
|
await session.delete(provider)
|
|
await session.commit()
|
|
await reinitialize_upstreams()
|
|
await refresh_model_maps()
|
|
return {"ok": True, "deleted_id": deleted_id}
|
|
|
|
|
|
@admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)])
|
|
async def get_provider_types() -> list[dict[str, object]]:
|
|
"""Get metadata about available provider types including default URLs and whether they're fixed."""
|
|
from ..upstream import upstream_provider_classes
|
|
|
|
return [cls.get_provider_metadata() for cls in upstream_provider_classes]
|
|
|
|
|
|
@admin_router.get(
|
|
"/api/upstream-providers/{provider_id}/models",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def get_provider_models(provider_id: str) -> dict[str, object]:
|
|
from ..upstream.helpers import _instantiate_provider
|
|
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
provider_pk = _provider_pk(provider)
|
|
|
|
db_models = await list_models(
|
|
session=session,
|
|
upstream_id=provider_pk,
|
|
include_disabled=True,
|
|
apply_fees=False,
|
|
)
|
|
|
|
upstream_models = []
|
|
upstream_instance = _instantiate_provider(provider)
|
|
if upstream_instance:
|
|
try:
|
|
raw_models = await upstream_instance.fetch_models()
|
|
upstream_models = raw_models
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to fetch models from {provider.provider_type}: {e}"
|
|
)
|
|
|
|
db_model_ids = {model.id for model in db_models}
|
|
filtered_remote_models = [
|
|
m for m in upstream_models if m.id not in db_model_ids
|
|
]
|
|
|
|
return {
|
|
"provider": {
|
|
"id": provider.id,
|
|
"provider_type": provider.provider_type,
|
|
"base_url": provider.base_url,
|
|
},
|
|
"db_models": [m.dict() for m in db_models],
|
|
"remote_models": [m.dict() for m in filtered_remote_models],
|
|
}
|
|
|
|
|
|
class CreateAccountRequest(BaseModel):
|
|
provider_type: str
|
|
|
|
|
|
@admin_router.post(
|
|
"/api/upstream-providers/create-account",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def create_provider_account_by_type(
|
|
payload: CreateAccountRequest,
|
|
) -> dict[str, object]:
|
|
"""Create a new account with a provider by provider type (before provider exists in DB)."""
|
|
from ..upstream import upstream_provider_classes
|
|
|
|
provider_class = next(
|
|
(
|
|
cls
|
|
for cls in upstream_provider_classes
|
|
if cls.provider_type == payload.provider_type
|
|
),
|
|
None,
|
|
)
|
|
if not provider_class:
|
|
raise HTTPException(status_code=404, detail="Provider type not found")
|
|
|
|
try:
|
|
account_data = await provider_class.create_account_static()
|
|
|
|
return {
|
|
"ok": True,
|
|
"account_data": account_data,
|
|
"message": "Account created successfully",
|
|
}
|
|
except NotImplementedError as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Provider does not support account creation: {str(e)}",
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to create account for provider type {payload.provider_type}: {e}"
|
|
)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
class TopupRequest(BaseModel):
|
|
amount: int
|
|
|
|
|
|
class TopupTokenRequest(BaseModel):
|
|
token: str
|
|
|
|
|
|
@admin_router.post(
|
|
"/api/upstream-providers/{provider_id}/topup-token",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def topup_provider_with_token(
|
|
provider_id: str, payload: TopupTokenRequest
|
|
) -> dict:
|
|
"""Redeem a Cashu token for an upstream provider."""
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
clean_url = provider.base_url.rstrip("/")
|
|
headers = {}
|
|
if provider.api_key:
|
|
headers["Authorization"] = f"Bearer {provider.api_key}"
|
|
resp = await client.post(
|
|
f"{clean_url}/v1/balance/topup",
|
|
json={"cashu_token": payload.token},
|
|
headers=headers,
|
|
)
|
|
|
|
if resp.status_code == 200:
|
|
return {"ok": True, "message": "Token redeemed successfully"}
|
|
else:
|
|
logger.error(f"Upstream token topup failed: {resp.text}")
|
|
try:
|
|
error_detail = resp.json()
|
|
except Exception:
|
|
error_detail = resp.text
|
|
return {"ok": False, "message": f"Upstream error: {error_detail}"}
|
|
|
|
|
|
@admin_router.post(
|
|
"/api/upstream-providers/{provider_id}/topup",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def initiate_provider_topup(
|
|
provider_id: str, payload: TopupRequest
|
|
) -> dict[str, object]:
|
|
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
|
from ..upstream.helpers import _instantiate_provider
|
|
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
|
|
try:
|
|
logger.info(
|
|
f"Initiating top-up for provider {provider_id}",
|
|
extra={"amount": payload.amount},
|
|
)
|
|
|
|
# For Routstr providers, we might be doing a Lightning top-up or a direct token transfer
|
|
if provider.provider_type == "routstr":
|
|
# UI sends sats for Routstr topup
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
clean_url = provider.base_url.rstrip("/")
|
|
request_json = {
|
|
"amount_sats": int(payload.amount),
|
|
"purpose": "topup",
|
|
"api_key": provider.api_key,
|
|
}
|
|
headers = (
|
|
{"Authorization": f"Bearer {provider.api_key}"}
|
|
if provider.api_key
|
|
else {}
|
|
)
|
|
|
|
last_status_code = 500
|
|
last_error_detail: object = "Failed to create top-up invoice"
|
|
|
|
# Some upstream Routstr nodes fail the first invoice request after warm-up
|
|
# and succeed immediately on retry. Retry once here so the UI stays single-click.
|
|
for attempt in range(2):
|
|
resp = await client.post(
|
|
f"{clean_url}/v1/balance/lightning/invoice",
|
|
json=request_json,
|
|
headers=headers,
|
|
)
|
|
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return {
|
|
"ok": True,
|
|
"topup_data": {
|
|
"payment_request": data.get("bolt11"),
|
|
"invoice_id": data.get("invoice_id"),
|
|
"status": "pending",
|
|
},
|
|
}
|
|
|
|
logger.error(
|
|
f"Upstream topup request failed: {resp.text}",
|
|
extra={
|
|
"provider_id": provider_id,
|
|
"attempt": attempt + 1,
|
|
"status_code": resp.status_code,
|
|
},
|
|
)
|
|
try:
|
|
last_error_detail = resp.json()
|
|
except Exception:
|
|
last_error_detail = resp.text
|
|
last_status_code = resp.status_code
|
|
|
|
if resp.status_code < 500 or attempt == 1:
|
|
break
|
|
|
|
await asyncio.sleep(0.2)
|
|
|
|
raise HTTPException(
|
|
status_code=last_status_code, detail=last_error_detail
|
|
)
|
|
|
|
upstream_instance = _instantiate_provider(provider)
|
|
if not upstream_instance:
|
|
raise HTTPException(
|
|
status_code=400, detail="Could not instantiate provider"
|
|
)
|
|
|
|
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
|
|
|
logger.info(
|
|
"Top-up initiated successfully",
|
|
extra={
|
|
"provider_id": provider_id,
|
|
"invoice_id": topup_data.invoice_id,
|
|
"amount": topup_data.amount,
|
|
},
|
|
)
|
|
|
|
response_data = {
|
|
"ok": True,
|
|
"topup_data": {
|
|
"invoice_id": topup_data.invoice_id,
|
|
"payment_request": topup_data.payment_request,
|
|
"amount": topup_data.amount,
|
|
"currency": topup_data.currency,
|
|
"expires_at": topup_data.expires_at,
|
|
"checkout_url": topup_data.checkout_url,
|
|
},
|
|
"message": "Top-up initiated successfully",
|
|
}
|
|
logger.info("Returning response", extra={"response": response_data})
|
|
return response_data
|
|
except NotImplementedError as e:
|
|
logger.error(f"Provider does not support top-up: {e}")
|
|
raise HTTPException(
|
|
status_code=400, detail=f"Provider does not support top-up: {str(e)}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to initiate top-up for provider {provider_id}: {e}",
|
|
extra={"error_type": type(e).__name__, "error": str(e)},
|
|
)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@admin_router.get(
|
|
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def check_topup_status(provider_id: str, invoice_id: str) -> dict[str, object]:
|
|
"""Check the status of a Lightning Network top-up invoice."""
|
|
from ..upstream.helpers import _instantiate_provider
|
|
from ..upstream.ppqai import PPQAIUpstreamProvider
|
|
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
|
|
# For Routstr providers, proxy the status check
|
|
if provider.provider_type == "routstr":
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
clean_url = provider.base_url.rstrip("/")
|
|
resp = await client.get(
|
|
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
|
|
headers={"Authorization": f"Bearer {provider.api_key}"}
|
|
if provider.api_key
|
|
else {},
|
|
)
|
|
if resp.status_code == 200:
|
|
status_data = resp.json()
|
|
return {"ok": True, "paid": status_data.get("status") == "paid"}
|
|
else:
|
|
logger.error(f"Upstream status check failed: {resp.text}")
|
|
return {"ok": False, "paid": False}
|
|
|
|
upstream_instance = _instantiate_provider(provider)
|
|
if not upstream_instance:
|
|
raise HTTPException(
|
|
status_code=400, detail="Could not instantiate provider"
|
|
)
|
|
|
|
if not isinstance(upstream_instance, PPQAIUpstreamProvider):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Provider does not support top-up status checking",
|
|
)
|
|
|
|
try:
|
|
paid = await upstream_instance.check_topup_status(invoice_id)
|
|
return {"ok": True, "paid": paid}
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to check top-up status for provider {provider_id}: {e}"
|
|
)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@admin_router.get(
|
|
"/api/upstream-providers/{provider_id}/balance",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def get_provider_balance(provider_id: str) -> dict[str, object]:
|
|
"""Get the current balance for an upstream provider account."""
|
|
from ..upstream.helpers import _instantiate_provider
|
|
|
|
async with create_session() as session:
|
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
|
|
|
# For Routstr providers, proxy the balance check
|
|
if provider.provider_type == "routstr":
|
|
import httpx
|
|
|
|
clean_url = provider.base_url.rstrip("/")
|
|
headers = {}
|
|
if provider.api_key:
|
|
headers["Authorization"] = f"Bearer {provider.api_key}"
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
try:
|
|
resp = await client.get(
|
|
f"{clean_url}/v1/balance/info",
|
|
headers=headers,
|
|
)
|
|
except httpx.TimeoutException as exc:
|
|
logger.error(
|
|
"Timed out fetching Routstr provider balance",
|
|
extra={
|
|
"provider_id": provider_id,
|
|
"base_url": clean_url,
|
|
"upstream_url": f"{clean_url}/v1/balance/info",
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
raise HTTPException(
|
|
status_code=504,
|
|
detail="Timed out contacting upstream Routstr provider",
|
|
) from exc
|
|
except httpx.RequestError as exc:
|
|
logger.error(
|
|
"Failed to fetch Routstr provider balance",
|
|
extra={
|
|
"provider_id": provider_id,
|
|
"base_url": clean_url,
|
|
"upstream_url": f"{clean_url}/v1/balance/info",
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="Failed to contact upstream Routstr provider",
|
|
) from exc
|
|
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
# Return balance in sats
|
|
balance = data.get("balance", 0)
|
|
if isinstance(balance, (int, float)):
|
|
return {"ok": True, "balance_data": balance // 1000}
|
|
return {"ok": True, "balance_data": balance}
|
|
else:
|
|
logger.error(f"Failed to fetch Routstr balance: {resp.text}")
|
|
return {"ok": False, "balance_data": None}
|
|
|
|
upstream_instance = _instantiate_provider(provider)
|
|
if not upstream_instance:
|
|
raise HTTPException(
|
|
status_code=400, detail="Could not instantiate provider"
|
|
)
|
|
|
|
try:
|
|
balance_data = await upstream_instance.get_balance()
|
|
return {"ok": True, "balance_data": balance_data}
|
|
except NotImplementedError as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Provider does not support balance checking: {str(e)}",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch balance for provider {provider_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@admin_router.get(
|
|
"/api/openrouter-presets",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def get_openrouter_presets() -> list[dict[str, object]]:
|
|
from ..payment.models import async_fetch_openrouter_models
|
|
|
|
models_data = await async_fetch_openrouter_models()
|
|
return models_data
|
|
|
|
|
|
@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)])
|
|
async def get_usage_metrics(
|
|
request: Request,
|
|
interval: int = Query(
|
|
default=15, ge=1, le=1440, description="Time interval in minutes"
|
|
),
|
|
hours: int = Query(
|
|
default=24,
|
|
ge=1,
|
|
le=MAX_USAGE_ANALYTICS_HOURS,
|
|
description="Hours of history to analyze",
|
|
),
|
|
) -> dict:
|
|
"""Get usage metrics aggregated by time interval."""
|
|
return log_manager.get_usage_metrics(interval=interval, hours=hours)
|
|
|
|
|
|
@admin_router.get("/api/usage/dashboard", dependencies=[Depends(require_admin_api)])
|
|
async def get_usage_dashboard(
|
|
request: Request,
|
|
interval: int = Query(
|
|
default=15, ge=1, le=1440, description="Time interval in minutes"
|
|
),
|
|
hours: int = Query(
|
|
default=24,
|
|
ge=1,
|
|
le=MAX_USAGE_ANALYTICS_HOURS,
|
|
description="Hours of history to analyze",
|
|
),
|
|
error_limit: int = Query(
|
|
default=100, ge=1, le=1000, description="Maximum number of errors to return"
|
|
),
|
|
model_limit: int = Query(
|
|
default=20, ge=1, le=100, description="Maximum number of models to return"
|
|
),
|
|
) -> dict:
|
|
"""
|
|
Get all dashboard analytics in one request.
|
|
This runs one combined aggregation pass and avoids repeated scans.
|
|
"""
|
|
return log_manager.get_usage_dashboard(
|
|
interval=interval,
|
|
hours=hours,
|
|
error_limit=error_limit,
|
|
model_limit=model_limit,
|
|
)
|
|
|
|
|
|
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
|
|
async def get_usage_summary(
|
|
request: Request,
|
|
hours: int = Query(
|
|
default=24,
|
|
ge=1,
|
|
le=MAX_USAGE_ANALYTICS_HOURS,
|
|
description="Hours of history to analyze",
|
|
),
|
|
) -> dict:
|
|
"""Get summary statistics for the specified time period."""
|
|
return log_manager.get_usage_summary(hours=hours)
|
|
|
|
|
|
@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)])
|
|
async def get_error_details(
|
|
request: Request,
|
|
hours: int = Query(
|
|
default=24,
|
|
ge=1,
|
|
le=MAX_USAGE_ANALYTICS_HOURS,
|
|
description="Hours of history to analyze",
|
|
),
|
|
limit: int = Query(
|
|
default=100, ge=1, le=1000, description="Maximum number of errors to return"
|
|
),
|
|
) -> dict:
|
|
"""Get detailed error information."""
|
|
return log_manager.get_error_details(hours=hours, limit=limit)
|
|
|
|
|
|
@admin_router.get(
|
|
"/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)]
|
|
)
|
|
async def get_revenue_by_model(
|
|
request: Request,
|
|
hours: int = Query(
|
|
default=24,
|
|
ge=1,
|
|
le=MAX_USAGE_ANALYTICS_HOURS,
|
|
description="Hours of history to analyze",
|
|
),
|
|
limit: int = Query(
|
|
default=20, ge=1, le=100, description="Maximum number of models to return"
|
|
),
|
|
) -> dict:
|
|
"""
|
|
Get revenue breakdown by model.
|
|
"""
|
|
return log_manager.get_revenue_by_model(hours=hours, limit=limit)
|
|
|
|
|
|
@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)])
|
|
async def get_logs_api(
|
|
request: Request,
|
|
date: str | None = None,
|
|
level: str | None = None,
|
|
request_id: str | None = None,
|
|
search: str | None = None,
|
|
status_codes: str | None = Query(None, description="Comma-separated status codes"),
|
|
methods: str | None = Query(None, description="Comma-separated HTTP methods"),
|
|
endpoints: str | None = Query(None, description="Comma-separated endpoints"),
|
|
limit: int = 100,
|
|
) -> dict[str, object]:
|
|
"""
|
|
Get filtered log entries.
|
|
|
|
Args:
|
|
date: Filter by specific date (YYYY-MM-DD)
|
|
level: Filter by log level
|
|
request_id: Filter by request ID
|
|
search: Search text in message and name fields (case-insensitive)
|
|
status_codes: Comma-separated list of HTTP status codes
|
|
methods: Comma-separated list of HTTP methods
|
|
endpoints: Comma-separated list of endpoints
|
|
limit: Maximum number of entries to return
|
|
|
|
Returns:
|
|
Dict containing logs and filter metadata
|
|
"""
|
|
status_code_list = None
|
|
if status_codes:
|
|
try:
|
|
status_code_list = [int(s.strip()) for s in status_codes.split(",")]
|
|
except ValueError:
|
|
pass
|
|
|
|
method_list = [m.strip() for m in methods.split(",")] if methods else None
|
|
endpoint_list = [e.strip() for e in endpoints.split(",")] if endpoints else None
|
|
|
|
log_entries = log_manager.search_logs(
|
|
date=date,
|
|
level=level,
|
|
request_id=request_id,
|
|
search_text=search,
|
|
status_codes=status_code_list,
|
|
methods=method_list,
|
|
endpoints=endpoint_list,
|
|
limit=limit,
|
|
)
|
|
|
|
return {
|
|
"logs": log_entries,
|
|
"total": len(log_entries),
|
|
"date": date,
|
|
"level": level,
|
|
"request_id": request_id,
|
|
"search": search,
|
|
"status_codes": status_codes,
|
|
"methods": methods,
|
|
"endpoints": endpoints,
|
|
"limit": limit,
|
|
}
|
|
|
|
|
|
@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)])
|
|
async def get_log_dates_api(request: Request) -> dict[str, object]:
|
|
logs_dir = Path("logs")
|
|
dates = []
|
|
|
|
if logs_dir.exists():
|
|
log_files = sorted(
|
|
logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True
|
|
)
|
|
|
|
for log_file in log_files[:30]:
|
|
try:
|
|
filename = log_file.name
|
|
date_str = filename.replace("app_", "").replace(".log", "")
|
|
dates.append(date_str)
|
|
except Exception:
|
|
continue
|
|
|
|
return {"dates": dates}
|
|
|
|
|
|
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
|
|
async def get_transactions_api(
|
|
type: str | None = None,
|
|
status: str | None = None,
|
|
search: str | None = None,
|
|
source: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> dict:
|
|
async with create_session() as session:
|
|
from sqlmodel import col, func
|
|
|
|
base = select(CashuTransaction)
|
|
if type:
|
|
base = base.where(CashuTransaction.type == type)
|
|
if source:
|
|
if source == "x-cashu":
|
|
base = base.where(
|
|
(CashuTransaction.source == "x-cashu")
|
|
| (CashuTransaction.source == None) # noqa: E711
|
|
)
|
|
else:
|
|
base = base.where(CashuTransaction.source == source)
|
|
if status:
|
|
if status == "collected":
|
|
base = base.where(CashuTransaction.collected == True) # noqa: E712
|
|
elif status == "swept":
|
|
base = base.where(CashuTransaction.swept == True) # noqa: E712
|
|
elif status == "pending":
|
|
base = base.where(
|
|
CashuTransaction.collected == False, # noqa: E712
|
|
CashuTransaction.swept == False, # noqa: E712
|
|
)
|
|
|
|
if search:
|
|
search_pattern = f"%{search}%"
|
|
base = base.where(
|
|
(col(CashuTransaction.id).like(search_pattern))
|
|
| (col(CashuTransaction.token).like(search_pattern))
|
|
| (col(CashuTransaction.request_id).like(search_pattern))
|
|
| (col(CashuTransaction.api_key_hashed_key).like(search_pattern))
|
|
)
|
|
|
|
count_result = await session.exec(
|
|
select(func.count()).select_from(base.subquery())
|
|
)
|
|
total = count_result.one()
|
|
|
|
stmt = base.order_by(col(CashuTransaction.created_at).desc()).offset(offset).limit(limit)
|
|
results = await session.exec(stmt)
|
|
transactions = results.all()
|
|
|
|
return {
|
|
"transactions": [tx.dict() for tx in transactions],
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@admin_router.get(
|
|
"/api/lightning-invoices", dependencies=[Depends(require_admin_api)]
|
|
)
|
|
async def get_lightning_invoices_api(
|
|
status: str | None = None,
|
|
purpose: str | None = None,
|
|
search: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> dict:
|
|
async with create_session() as session:
|
|
from sqlmodel import col, func
|
|
|
|
base = select(LightningInvoice)
|
|
if status:
|
|
base = base.where(LightningInvoice.status == status)
|
|
if purpose:
|
|
base = base.where(LightningInvoice.purpose == purpose)
|
|
if search:
|
|
pattern = f"%{search}%"
|
|
base = base.where(
|
|
(col(LightningInvoice.id).like(pattern))
|
|
| (col(LightningInvoice.bolt11).like(pattern))
|
|
| (col(LightningInvoice.payment_hash).like(pattern))
|
|
| (col(LightningInvoice.api_key_hash).like(pattern))
|
|
)
|
|
|
|
count_result = await session.exec(
|
|
select(func.count()).select_from(base.subquery())
|
|
)
|
|
total = count_result.one()
|
|
|
|
stmt = (
|
|
base.order_by(col(LightningInvoice.created_at).desc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
results = await session.exec(stmt)
|
|
invoices = results.all()
|
|
|
|
return {
|
|
"invoices": [inv.dict() for inv in invoices],
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@admin_router.post(
|
|
"/api/upstream-providers/{provider_id}/routstr/refund",
|
|
dependencies=[Depends(require_admin_api)],
|
|
)
|
|
async def refund_routstr_provider_balance(provider_id: str) -> dict[str, object]:
|
|
"""Refund balance from an upstream Routstr provider back to the local wallet."""
|
|
from ..upstream.helpers import _instantiate_provider
|
|
from ..upstream.routstr import RoutstrUpstreamProvider
|
|
|
|
async with create_session() as session:
|
|
provider_row = await _get_upstream_provider_by_ref(session, provider_id)
|
|
|
|
if provider_row.provider_type != "routstr":
|
|
raise HTTPException(
|
|
status_code=400, detail="Refund only supported for Routstr providers"
|
|
)
|
|
|
|
provider = _instantiate_provider(provider_row)
|
|
if not isinstance(provider, RoutstrUpstreamProvider):
|
|
raise HTTPException(status_code=400, detail="Invalid provider instance")
|
|
|
|
try:
|
|
# Request refund from upstream
|
|
data = await provider.refund_balance()
|
|
if "error" in data:
|
|
# If the upstream returned an OpenAI-style error (like the model unknown error)
|
|
# it means the request likely didn't even reach the refund endpoint handler
|
|
# but was intercepted by the proxy layer.
|
|
error_info = data.get("error", {})
|
|
message = (
|
|
error_info.get("message")
|
|
if isinstance(error_info, dict)
|
|
else str(error_info)
|
|
)
|
|
return {
|
|
"ok": False,
|
|
"message": f"Upstream refund failed: {message}",
|
|
}
|
|
|
|
token = data.get("token")
|
|
if not token:
|
|
return {"ok": False, "message": "Upstream did not return a token"}
|
|
|
|
# Receive token into local wallet
|
|
from ..wallet import recieve_token
|
|
|
|
try:
|
|
# Use current wallet to receive
|
|
await recieve_token(token)
|
|
return {
|
|
"ok": True,
|
|
"message": "Successfully received refund from upstream provider",
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to receive refund token: {e}")
|
|
return {
|
|
"ok": False,
|
|
"message": f"Failed to receive refund token: {str(e)}",
|
|
"token": token,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Refund failed for provider {provider_id}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|