mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
61 Commits
refactor/r
...
add-cost-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e794b09614 | ||
|
|
24f6519267 | ||
|
|
f57beb6411 | ||
|
|
3e41e59a1d | ||
|
|
002d750830 | ||
|
|
8c2eb55760 | ||
|
|
6fbd479bdc | ||
|
|
f67c26935a | ||
|
|
9ce91f58a9 | ||
|
|
bb82361434 | ||
|
|
5f1d67e87e | ||
|
|
b2106ad1e6 | ||
|
|
709a4ba0dc | ||
|
|
21f421b212 | ||
|
|
b3c5e4cbf6 | ||
|
|
1d95379328 | ||
|
|
48529f672a | ||
|
|
030c2f65e4 | ||
|
|
3510402af2 | ||
|
|
5f376d716d | ||
|
|
d889274f84 | ||
|
|
0c0f19d854 | ||
|
|
b61bffc666 | ||
|
|
af658136d4 | ||
|
|
8973627b5f | ||
|
|
25f427033a | ||
|
|
d3dd8318e4 | ||
|
|
52c7f17215 | ||
|
|
2c03302055 | ||
|
|
c3221f2a31 | ||
|
|
58fa063c6b | ||
|
|
7c94f60797 | ||
|
|
4cb4c6dfec | ||
|
|
512b686e5f | ||
|
|
f495a10eeb | ||
|
|
d1692edb63 | ||
|
|
8f81bcd2fc | ||
|
|
6a5ed9d063 | ||
|
|
b9890e6ad5 | ||
|
|
e4b8293d41 | ||
|
|
ba5f9fc181 | ||
|
|
795fff61e0 | ||
|
|
f9bfd4f0d2 | ||
|
|
5683382ada | ||
|
|
c92372dafd | ||
|
|
b58dd78fde | ||
|
|
4c31bf9767 | ||
|
|
42efa3c1ba | ||
|
|
74b1d39d5c | ||
|
|
4df4976f44 | ||
|
|
4aa57959bf | ||
|
|
b1facd58d5 | ||
|
|
6288d6fef7 | ||
|
|
a1223ad610 | ||
|
|
c75f170ed0 | ||
|
|
248937e05f | ||
|
|
31898192b2 | ||
|
|
e50facc835 | ||
|
|
55dc485705 | ||
|
|
80559a57d5 | ||
|
|
8e9f6647e7 |
50
Dockerfile.full
Normal file
50
Dockerfile.full
Normal file
@@ -0,0 +1,50 @@
|
||||
# Multi-stage Dockerfile for Routstr (includes UI build)
|
||||
# Stage 1: Build the UI
|
||||
FROM node:23-alpine AS ui-builder
|
||||
WORKDIR /app/ui
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy UI source
|
||||
COPY ui/package.json ui/pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ui/ ./
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Next.js build produces a static export in 'out' directory
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Build the Routstr Node
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-alpine AS runner
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
pkgconf \
|
||||
build-base \
|
||||
automake \
|
||||
autoconf \
|
||||
libtool \
|
||||
m4 \
|
||||
perl \
|
||||
git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the rest of the application (required for uv sync to find the package)
|
||||
COPY . .
|
||||
|
||||
# Install dependencies including the specific secp256k1 branch
|
||||
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
|
||||
RUN uv sync --no-dev
|
||||
|
||||
# Copy the built UI from the ui-builder stage
|
||||
COPY --from=ui-builder /app/ui/out ./ui_out
|
||||
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile.build
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
|
||||
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
|
||||
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
|
||||
volumes:
|
||||
- ./ui_out:/output
|
||||
|
||||
@@ -360,6 +360,42 @@ POST /v1/wallet/create
|
||||
}
|
||||
```
|
||||
|
||||
### Get Key Information
|
||||
|
||||
Get current balance, consumption data, and child keys for an API key.
|
||||
|
||||
```http
|
||||
GET /v1/balance/info
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-abc...",
|
||||
"balance": 8500000,
|
||||
"reserved": 0,
|
||||
"is_child": false,
|
||||
"parent_key": null,
|
||||
"total_requests": 42,
|
||||
"total_spent": 1500000,
|
||||
"balance_limit": null,
|
||||
"balance_limit_reset": null,
|
||||
"validity_date": null,
|
||||
"child_keys": [
|
||||
{
|
||||
"api_key": "sk-child1...",
|
||||
"total_requests": 10,
|
||||
"total_spent": 500000,
|
||||
"balance_limit": 1000000,
|
||||
"balance_limit_reset": "daily",
|
||||
"validity_date": 1738000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current wallet balance.
|
||||
|
||||
@@ -6,6 +6,32 @@ For automated deployments, you can optionally pre-configure settings via environ
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup (.env file)
|
||||
|
||||
Before running your node, you should create a `.env` file in the project root. This file is used to bootstrap the initial configuration and store sensitive secrets.
|
||||
|
||||
### Example .env
|
||||
|
||||
```bash
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# Node Identity
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
|
||||
# Lightning Payouts
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
```
|
||||
|
||||
### Setting the UI Password
|
||||
|
||||
There are two ways to set or change your Admin Dashboard password:
|
||||
|
||||
1. **Via Environment Variable**: Set `ADMIN_PASSWORD` in your `.env` file before starting the container. This will be the password used for the first login.
|
||||
2. **Via Dashboard**: Once logged in, go to **Settings** → **Security** to update your password. Dashboard settings override the `.env` file once saved.
|
||||
|
||||
---
|
||||
|
||||
## Admin Dashboard (Primary)
|
||||
|
||||
Access the dashboard at `/admin/` on your node.
|
||||
@@ -14,29 +40,29 @@ Access the dashboard at `/admin/` on your node.
|
||||
|
||||
Connect to your AI provider(s):
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Setting | Description |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
|
||||
| **API Key** | Your provider's API key |
|
||||
| **API Key** | Your provider's API key |
|
||||
|
||||
### Node Identity
|
||||
|
||||
How your node appears to clients:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
|
||||
| **Description** | Brief description of your service |
|
||||
| Setting | Description |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
|
||||
| **Description** | Brief description of your service |
|
||||
|
||||
### Pricing
|
||||
|
||||
Control your profit margins:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
|
||||
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
|
||||
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
|
||||
| Setting | Description | Default |
|
||||
| ----------------- | ------------------------------------------ | ------------ |
|
||||
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
|
||||
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
|
||||
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
|
||||
|
||||
See [Pricing](pricing.md) for detailed strategies.
|
||||
|
||||
@@ -44,33 +70,33 @@ See [Pricing](pricing.md) for detailed strategies.
|
||||
|
||||
Which mints to accept payments from:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Setting | Description |
|
||||
| --------- | ------------------------------- |
|
||||
| **Mints** | List of trusted Cashu mint URLs |
|
||||
|
||||
### Lightning Withdrawals
|
||||
|
||||
Automatic profit withdrawal:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Setting | Description |
|
||||
| --------------------- | ------------------------------- |
|
||||
| **Lightning Address** | Your LN address for withdrawals |
|
||||
|
||||
### Security
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Setting | Description |
|
||||
| ------------------ | ----------------------------- |
|
||||
| **Admin Password** | Password for dashboard access |
|
||||
|
||||
### Nostr Discovery
|
||||
|
||||
Announce your node on the network:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Npub** | Your Nostr public key |
|
||||
| **Nsec** | Your Nostr private key (for signing) |
|
||||
| **Relays** | Relays to publish announcements |
|
||||
| Setting | Description |
|
||||
| ---------- | ------------------------------------ |
|
||||
| **Npub** | Your Nostr public key |
|
||||
| **Nsec** | Your Nostr private key (for signing) |
|
||||
| **Relays** | Relays to publish announcements |
|
||||
|
||||
See [Discovery](discovery.md) for details.
|
||||
|
||||
@@ -86,21 +112,21 @@ Use environment variables for:
|
||||
|
||||
### All Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
|
||||
| `UPSTREAM_API_KEY` | Upstream API key | — |
|
||||
| `ADMIN_PASSWORD` | Dashboard password | (none) |
|
||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
|
||||
| `NAME` | Node display name | `ARoutstrNode` |
|
||||
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
||||
| `NPUB` | Nostr public key (bech32) | — |
|
||||
| `NSEC` | Nostr private key | — |
|
||||
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
||||
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
|
||||
| Variable | Description | Default |
|
||||
| -------------------- | --------------------------------- | ------------------------------------ |
|
||||
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
|
||||
| `UPSTREAM_API_KEY` | Upstream API key | — |
|
||||
| `ADMIN_PASSWORD` | Dashboard password | (none) |
|
||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
|
||||
| `NAME` | Node display name | `ARoutstrNode` |
|
||||
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
||||
| `NPUB` | Nostr public key (bech32) | — |
|
||||
| `NSEC` | Nostr private key | — |
|
||||
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
||||
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
|
||||
|
||||
### Priority
|
||||
|
||||
|
||||
@@ -6,30 +6,25 @@ Production deployment guide for Routstr Provider nodes.
|
||||
|
||||
For production, use Docker Compose with persistent storage and optional Tor support.
|
||||
|
||||
### Basic Setup
|
||||
### Unified Setup (All-in-one)
|
||||
To build and run the node with the UI integrated in a single container using the multi-stage build:
|
||||
|
||||
Create a `compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
routstr:
|
||||
image: ghcr.io/routstr/proxy:latest
|
||||
container_name: routstr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
```bash
|
||||
docker build -f Dockerfile.full -t routstr-full .
|
||||
docker run -d -p 8000:8000 --env-file .env routstr-full
|
||||
```
|
||||
|
||||
Start the node:
|
||||
### Advanced Setup (Separated UI & Node)
|
||||
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/).
|
||||
This will:
|
||||
1. **Build the UI**: Compiles the frontend and copies it to a shared volume.
|
||||
2. **Start Routstr**: Runs the Python node, mounting the built UI.
|
||||
3. **Start Tor**: Provides anonymous access via a `.onion` address.
|
||||
|
||||
---
|
||||
|
||||
@@ -189,8 +184,20 @@ docker compose up -d
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Unified Image (UI + Node)
|
||||
The easiest way to build everything from source into a single production-ready image:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
docker build -t routstr-local .
|
||||
docker build -f Dockerfile.full -t routstr-full .
|
||||
```
|
||||
|
||||
### Individual Components
|
||||
If you prefer building them separately or using Docker Compose:
|
||||
|
||||
```bash
|
||||
# Build using compose
|
||||
docker compose build
|
||||
|
||||
# Or build the node only (requires manual UI build first)
|
||||
docker build -t routstr-node .
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ A **Routstr Provider Node** acts as a gateway that:
|
||||
You bring the API keys, Routstr handles the billing, payments, and client management.
|
||||
|
||||
!!! tip "Future: Node-to-Node Routing"
|
||||
In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials.
|
||||
In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials.
|
||||
|
||||
---
|
||||
|
||||
@@ -24,16 +24,53 @@ You bring the API keys, Routstr handles the billing, payments, and client manage
|
||||
|
||||
---
|
||||
|
||||
## 1. Start the Node
|
||||
## 1. Prepare Configuration
|
||||
|
||||
Create a `.env` file in the root of the project to store your secrets:
|
||||
|
||||
```bash
|
||||
# Initial Admin Password
|
||||
ADMIN_PASSWORD=mysecretpassword
|
||||
|
||||
# Node Identity
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
|
||||
# Lightning Payouts
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
|
||||
```
|
||||
|
||||
## 2. Start the Node
|
||||
|
||||
You can run the pre-built image directly:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
--env-file .env \
|
||||
-v routstr-data:/app/data \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
*Note: The pre-built image does not contain the UI. For the all-in-one experience with the Admin Dashboard, use the Build from Source instructions below.*
|
||||
|
||||
### Build from Source (Recommended)
|
||||
|
||||
If you want to build the node and UI yourself from source, use the unified Dockerfile:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
# Edit your .env with ADMIN_PASSWORD and API keys
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
docker build -f Dockerfile.full -t routstr-local .
|
||||
docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
|
||||
```bash
|
||||
@@ -42,12 +79,12 @@ curl http://localhost:8000/v1/info
|
||||
|
||||
---
|
||||
|
||||
## 2. Configure via Dashboard
|
||||
## 3. Configure via Dashboard
|
||||
|
||||
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
|
||||
|
||||
!!! note "Default Access"
|
||||
The dashboard has no password by default. Set one immediately in Settings for production use.
|
||||
!!! note "Login"
|
||||
Use the `ADMIN_PASSWORD` you defined in your `.env` file to log in. If you didn't set one, the dashboard will prompt you to set one on first visit.
|
||||
|
||||
### Connect Your AI Providers
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add key management and reset fields to api_keys
|
||||
|
||||
Revision ID: 06f81c0fc88d
|
||||
Revises: c2d3e4f5a6b7
|
||||
Create Date: 2026-02-04 22:44:03.311983
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "06f81c0fc88d"
|
||||
down_revision = "c2d3e4f5a6b7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("api_keys", sa.Column("balance_limit", sa.Integer(), nullable=True))
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column(
|
||||
"balance_limit_reset", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"api_keys", sa.Column("balance_limit_reset_date", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column("api_keys", sa.Column("validity_date", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("api_keys", "validity_date")
|
||||
op.drop_column("api_keys", "balance_limit_reset_date")
|
||||
op.drop_column("api_keys", "balance_limit_reset")
|
||||
op.drop_column("api_keys", "balance_limit")
|
||||
282
routstr/auth.py
282
routstr/auth.py
@@ -1,10 +1,14 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import col, update
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .core import get_logger
|
||||
from .core.db import ApiKey, AsyncSession
|
||||
@@ -24,16 +28,60 @@ logger = get_logger(__name__)
|
||||
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
|
||||
|
||||
|
||||
async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
|
||||
"""Checks if a key's balance limit should be reset based on its policy."""
|
||||
if key.balance_limit is not None and key.balance_limit_reset:
|
||||
now = int(time.time())
|
||||
reset_date = key.balance_limit_reset_date or 0
|
||||
should_reset = False
|
||||
|
||||
if key.balance_limit_reset == "daily":
|
||||
if (
|
||||
datetime.fromtimestamp(now).date()
|
||||
> datetime.fromtimestamp(reset_date).date()
|
||||
):
|
||||
should_reset = True
|
||||
elif key.balance_limit_reset == "weekly":
|
||||
if (
|
||||
datetime.fromtimestamp(now).isocalendar()[:2]
|
||||
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
|
||||
):
|
||||
should_reset = True
|
||||
elif key.balance_limit_reset == "monthly":
|
||||
dt_now = datetime.fromtimestamp(now)
|
||||
dt_reset = datetime.fromtimestamp(reset_date)
|
||||
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
|
||||
should_reset = True
|
||||
|
||||
if should_reset:
|
||||
logger.info(
|
||||
"Resetting balance limit for key",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"policy": key.balance_limit_reset,
|
||||
"old_spent": key.total_spent,
|
||||
},
|
||||
)
|
||||
key.total_spent = 0
|
||||
key.balance_limit_reset_date = now
|
||||
session.add(key)
|
||||
await session.flush()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: Optional[str] = None,
|
||||
key_expiry_time: Optional[int] = None,
|
||||
min_cost: int = 0,
|
||||
) -> ApiKey:
|
||||
"""
|
||||
Validates the provided API key using SQLModel.
|
||||
If it's a cashu key, it redeems it and stores its hash and balance.
|
||||
Otherwise checks if the hash of the key exists.
|
||||
Includes a balance check against min_cost for limited keys.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting bearer key validation",
|
||||
@@ -43,6 +91,7 @@ async def validate_bearer_key(
|
||||
else bearer_key,
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
"min_cost": min_cost,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -97,6 +146,50 @@ async def validate_bearer_key(
|
||||
},
|
||||
)
|
||||
|
||||
# Check and reset limit if needed
|
||||
await check_and_reset_limit(existing_key, session)
|
||||
|
||||
# Early check: Billing balance check (Parent balance)
|
||||
billing_key = await get_billing_key(existing_key, session)
|
||||
if min_cost > 0 and billing_key.total_balance < min_cost:
|
||||
logger.warning(
|
||||
"Insufficient billing balance during validation",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"balance": billing_key.total_balance,
|
||||
"required": min_cost,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {min_cost} mSats required for this model. {billing_key.total_balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Early check: Spending limit check (Child key limit)
|
||||
if (
|
||||
min_cost > 0
|
||||
and existing_key.balance_limit is not None
|
||||
and existing_key.total_spent + existing_key.reserved_balance + min_cost
|
||||
> existing_key.balance_limit
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Balance limit exceeded: {existing_key.balance_limit} mSats limit. {existing_key.total_spent} already spent ({existing_key.reserved_balance} reserved), {min_cost} minimum required for this model.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "balance_limit_exceeded",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
else:
|
||||
logger.warning(
|
||||
@@ -152,6 +245,19 @@ async def validate_bearer_key(
|
||||
},
|
||||
)
|
||||
|
||||
# Early check: Billing balance check
|
||||
if min_cost > 0 and existing_key.total_balance < min_cost:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {min_cost} mSats required for this model. {existing_key.total_balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
|
||||
logger.info(
|
||||
@@ -311,6 +417,8 @@ async def pay_for_request(
|
||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
# Ensure cost_per_request is at least the minimum allowed request cost
|
||||
cost_per_request = max(cost_per_request, settings.min_request_msat)
|
||||
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
@@ -349,6 +457,57 @@ async def pay_for_request(
|
||||
},
|
||||
)
|
||||
|
||||
# Check validity date
|
||||
if key.validity_date is not None:
|
||||
if time.time() > key.validity_date:
|
||||
logger.warning(
|
||||
"Key validity date expired",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"validity_date": key.validity_date,
|
||||
"current_time": time.time(),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "API key has expired (validity date reached).",
|
||||
"type": "invalid_request_error",
|
||||
"code": "key_expired",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Check balance limit for child keys (or any key with a limit)
|
||||
if key.balance_limit is not None:
|
||||
await check_and_reset_limit(key, session)
|
||||
|
||||
if (
|
||||
key.total_spent + key.reserved_balance + cost_per_request
|
||||
> key.balance_limit
|
||||
):
|
||||
logger.warning(
|
||||
"Balance limit exceeded",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"total_spent": key.total_spent,
|
||||
"reserved": key.reserved_balance,
|
||||
"balance_limit": key.balance_limit,
|
||||
"required": cost_per_request,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "balance_limit_exceeded",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
@@ -371,12 +530,15 @@ async def pay_for_request(
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also increment total_requests on the child key if it's different
|
||||
# Also increment total_requests and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_requests=col(ApiKey.total_requests) + 1)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
@@ -440,12 +602,15 @@ async def revert_pay_for_request(
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also decrement total_requests on the child key if it's different
|
||||
# Also decrement total_requests and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_requests=col(ApiKey.total_requests) - 1)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
@@ -509,6 +674,19 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also release on child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost
|
||||
)
|
||||
)
|
||||
await session.exec(child_release_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
"Released reservation without charging (fallback)",
|
||||
@@ -551,12 +729,16 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + cost.total_msats)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
@@ -631,12 +813,16 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
@@ -673,12 +859,16 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
@@ -737,12 +927,16 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
@@ -815,3 +1009,65 @@ async def adjust_payment_for_tokens(
|
||||
"output_msats": 0,
|
||||
"total_msats": deducted_max_cost,
|
||||
}
|
||||
|
||||
|
||||
async def periodic_key_reset() -> None:
|
||||
"""Background task to reset key limits based on their policy."""
|
||||
from .core.db import create_session
|
||||
|
||||
while True:
|
||||
try:
|
||||
interval = 3600 # Run every hour
|
||||
jitter = 300
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
try:
|
||||
async with create_session() as session:
|
||||
# Find all keys that have a reset policy
|
||||
stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore
|
||||
keys = (await session.exec(stmt)).all()
|
||||
|
||||
now = int(time.time())
|
||||
updated_count = 0
|
||||
|
||||
for key in keys:
|
||||
reset_date = key.balance_limit_reset_date or 0
|
||||
should_reset = False
|
||||
|
||||
if key.balance_limit_reset == "daily":
|
||||
if (
|
||||
datetime.fromtimestamp(now).date()
|
||||
> datetime.fromtimestamp(reset_date).date()
|
||||
):
|
||||
should_reset = True
|
||||
elif key.balance_limit_reset == "weekly":
|
||||
if (
|
||||
datetime.fromtimestamp(now).isocalendar()[:2]
|
||||
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
|
||||
):
|
||||
should_reset = True
|
||||
elif key.balance_limit_reset == "monthly":
|
||||
dt_now = datetime.fromtimestamp(now)
|
||||
dt_reset = datetime.fromtimestamp(reset_date)
|
||||
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
|
||||
should_reset = True
|
||||
|
||||
if should_reset:
|
||||
key.total_spent = 0
|
||||
key.balance_limit_reset_date = now
|
||||
session.add(key)
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Periodic key reset complete",
|
||||
extra={"keys_reset": updated_count},
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in periodic_key_reset: {e}")
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from time import monotonic
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from .auth import validate_bearer_key
|
||||
from .auth import get_billing_key, validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
@@ -33,10 +35,8 @@ async def get_key_from_header(
|
||||
|
||||
|
||||
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||
from .auth import get_billing_key
|
||||
|
||||
billing_key = await get_billing_key(key, session)
|
||||
return {
|
||||
info = {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": billing_key.balance,
|
||||
"reserved": billing_key.reserved_balance,
|
||||
@@ -44,8 +44,31 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
|
||||
"total_requests": key.total_requests,
|
||||
"total_spent": key.total_spent,
|
||||
"balance_limit": key.balance_limit,
|
||||
"balance_limit_reset": key.balance_limit_reset,
|
||||
"validity_date": key.validity_date,
|
||||
}
|
||||
|
||||
if not key.parent_key_hash:
|
||||
# Fetch child keys if this is a parent key
|
||||
statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
|
||||
results = await session.exec(statement)
|
||||
child_keys = results.all()
|
||||
if child_keys:
|
||||
info["child_keys"] = [
|
||||
{
|
||||
"api_key": "sk-" + ck.hashed_key,
|
||||
"total_requests": ck.total_requests,
|
||||
"total_spent": ck.total_spent,
|
||||
"balance_limit": ck.balance_limit,
|
||||
"balance_limit_reset": ck.balance_limit_reset,
|
||||
"validity_date": ck.validity_date,
|
||||
}
|
||||
for ck in child_keys
|
||||
]
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@router.get("/", include_in_schema=False)
|
||||
@@ -70,9 +93,24 @@ async def account_info(
|
||||
|
||||
@router.get("/create")
|
||||
async def create_balance(
|
||||
initial_balance_token: str, session: AsyncSession = Depends(get_session)
|
||||
initial_balance_token: str,
|
||||
balance_limit: int | None = None,
|
||||
balance_limit_reset: str | None = None,
|
||||
validity_date: int | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
key = await validate_bearer_key(initial_balance_token, session)
|
||||
|
||||
if balance_limit is not None or balance_limit_reset or validity_date:
|
||||
key.balance_limit = balance_limit
|
||||
key.balance_limit_reset = balance_limit_reset
|
||||
key.validity_date = validity_date
|
||||
if balance_limit_reset:
|
||||
key.balance_limit_reset_date = int(time.time())
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
@@ -98,8 +136,6 @@ async def topup_wallet_endpoint(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
from .auth import get_billing_key
|
||||
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
if topup_request is not None:
|
||||
@@ -167,11 +203,11 @@ async def refund_wallet_endpoint(
|
||||
|
||||
bearer_value: str = authorization[7:]
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -232,7 +268,9 @@ async def refund_wallet_endpoint(
|
||||
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
await session.delete(key)
|
||||
key.balance = 0
|
||||
key.reserved_balance = 0
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
return result
|
||||
@@ -253,6 +291,9 @@ async def donate(token: str, ref: str | None = None) -> str:
|
||||
|
||||
class ChildKeyRequest(BaseModel):
|
||||
count: int
|
||||
balance_limit: int | None = None
|
||||
balance_limit_reset: str | None = None
|
||||
validity_date: int | None = None
|
||||
|
||||
|
||||
@router.post("/child-key")
|
||||
@@ -302,6 +343,12 @@ async def create_child_key(
|
||||
hashed_key=new_key_hash,
|
||||
balance=0,
|
||||
parent_key_hash=key.hashed_key,
|
||||
balance_limit=payload.balance_limit,
|
||||
balance_limit_reset=payload.balance_limit_reset,
|
||||
balance_limit_reset_date=int(time.time())
|
||||
if payload.balance_limit_reset
|
||||
else None,
|
||||
validity_date=payload.validity_date,
|
||||
)
|
||||
session.add(child_key)
|
||||
new_keys.append("sk-" + new_key_hash)
|
||||
@@ -320,6 +367,39 @@ async def create_child_key(
|
||||
return response_data
|
||||
|
||||
|
||||
class ChildKeyResetRequest(BaseModel):
|
||||
child_key: str
|
||||
|
||||
|
||||
@router.post("/child-key/reset")
|
||||
async def reset_child_key_spent(
|
||||
payload: ChildKeyResetRequest,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Resets the total_spent of a child key. Must be called by the parent."""
|
||||
child_key_raw = payload.child_key
|
||||
if child_key_raw.startswith("sk-"):
|
||||
child_key_raw = child_key_raw[3:]
|
||||
|
||||
child_key = await session.get(ApiKey, child_key_raw)
|
||||
if not child_key:
|
||||
raise HTTPException(status_code=404, detail="Child key not found.")
|
||||
|
||||
if child_key.parent_key_hash != key.hashed_key:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Unauthorized. You are not the parent of this key."
|
||||
)
|
||||
|
||||
child_key.total_spent = 0
|
||||
if child_key.balance_limit_reset:
|
||||
child_key.balance_limit_reset_date = int(time.time())
|
||||
session.add(child_key)
|
||||
await session.commit()
|
||||
|
||||
return {"success": True, "message": "Child key balance reset successfully."}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
|
||||
@@ -55,11 +55,50 @@ async def get_temporary_balances_api(request: Request) -> list[dict[str, object]
|
||||
"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,
|
||||
}
|
||||
for key in api_keys
|
||||
]
|
||||
|
||||
|
||||
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()
|
||||
@@ -402,6 +441,102 @@ async def delete_all_provider_models(provider_id: int) -> dict[str, object]:
|
||||
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: int, 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 session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
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_id))
|
||||
|
||||
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_id,
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
class UpstreamProviderCreate(BaseModel):
|
||||
provider_type: str
|
||||
base_url: str
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
@@ -51,6 +53,22 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
parent_key_hash: str | None = Field(
|
||||
default=None, foreign_key="api_keys.hashed_key", index=True
|
||||
)
|
||||
balance_limit: int | None = Field(
|
||||
default=None,
|
||||
description="Max spendable balance in msats for this key (mostly for child keys)",
|
||||
)
|
||||
balance_limit_reset: str | None = Field(
|
||||
default=None,
|
||||
description="Reset policy for balance limit (manual, daily, monthly, etc.)",
|
||||
)
|
||||
balance_limit_reset_date: int | None = Field(
|
||||
default=None,
|
||||
description="Unix timestamp of the last time the balance limit was reset",
|
||||
)
|
||||
validity_date: int | None = Field(
|
||||
default=None,
|
||||
description="Unix timestamp after which the key is no longer valid",
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
@@ -113,7 +131,9 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "upstream_providers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
|
||||
UniqueConstraint(
|
||||
"base_url", "api_key", name="uq_upstream_providers_base_url_api_key"
|
||||
),
|
||||
)
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
provider_type: str = Field(
|
||||
@@ -163,11 +183,53 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield session
|
||||
|
||||
|
||||
def fix_cashu_migrations() -> None:
|
||||
"""
|
||||
Fixes Cashu wallet migrations that are not idempotent.
|
||||
This specifically addresses the 'duplicate column name: public_keys' error
|
||||
in the keysets table of Cashu's internal SQLite databases.
|
||||
"""
|
||||
project_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
wallet_dir = project_root / ".wallet"
|
||||
|
||||
if not wallet_dir.exists() or not wallet_dir.is_dir():
|
||||
return
|
||||
|
||||
logger.info("Checking Cashu wallet databases for migration idempotency")
|
||||
|
||||
for db_file in wallet_dir.glob("*.sqlite3"):
|
||||
try:
|
||||
conn = sqlite3.connect(db_file)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if keysets table exists
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
continue
|
||||
|
||||
# Check if public_keys column exists
|
||||
cursor.execute("PRAGMA table_info(keysets)")
|
||||
columns = [info[1] for info in cursor.fetchall()]
|
||||
|
||||
if "public_keys" not in columns:
|
||||
logger.info(f"Adding missing public_keys column to {db_file.name}")
|
||||
cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT")
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
"""Run Alembic migrations programmatically."""
|
||||
import pathlib
|
||||
|
||||
try:
|
||||
# Run Cashu migration fix first
|
||||
fix_cashu_migrations()
|
||||
|
||||
# Get the path to the alembic.ini file
|
||||
project_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
alembic_ini_path = project_root / "alembic.ini"
|
||||
|
||||
@@ -10,9 +10,10 @@ from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from ..auth import periodic_key_reset
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import providers_cache_refresher, providers_router
|
||||
from ..nip91 import announce_provider
|
||||
from ..nostr import announce_provider, providers_cache_refresher
|
||||
from ..nostr.discovery import providers_router
|
||||
from ..payment.models import models_router, update_sats_pricing
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
@@ -46,6 +47,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
@@ -101,6 +103,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
if global_settings.providers_refresh_interval_seconds > 0:
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
key_reset_task = asyncio.create_task(periodic_key_reset())
|
||||
|
||||
yield
|
||||
|
||||
@@ -130,6 +133,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
models_refresh_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
if key_reset_task is not None:
|
||||
key_reset_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
@@ -147,6 +152,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
if key_reset_task is not None:
|
||||
tasks_to_wait.append(key_reset_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
|
||||
@@ -143,7 +143,7 @@ def resolve_bootstrap() -> Settings:
|
||||
pass
|
||||
if not base.onion_url:
|
||||
try:
|
||||
from ..nip91 import discover_onion_url_from_tor # type: ignore
|
||||
from ..nostr.listing import discover_onion_url_from_tor # type: ignore
|
||||
|
||||
discovered = discover_onion_url_from_tor()
|
||||
if discovered:
|
||||
|
||||
@@ -23,6 +23,9 @@ class InvoiceCreateRequest(BaseModel):
|
||||
api_key: str | None = Field(
|
||||
default=None, description="Required for topup operations"
|
||||
)
|
||||
balance_limit: int | None = Field(default=None)
|
||||
balance_limit_reset: str | None = Field(default=None)
|
||||
validity_date: int | None = Field(default=None)
|
||||
|
||||
|
||||
class InvoiceCreateResponse(BaseModel):
|
||||
@@ -94,6 +97,9 @@ async def create_invoice(
|
||||
status="pending",
|
||||
api_key_hash=request.api_key[3:] if request.api_key else None,
|
||||
purpose=request.purpose,
|
||||
balance_limit=request.balance_limit,
|
||||
balance_limit_reset=request.balance_limit_reset,
|
||||
validity_date=request.validity_date,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
4
routstr/nostr/__init__.py
Normal file
4
routstr/nostr/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .discovery import providers_cache_refresher
|
||||
from .listing import announce_provider
|
||||
|
||||
__all__ = ["providers_cache_refresher", "announce_provider"]
|
||||
@@ -8,8 +8,8 @@ import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -72,8 +72,6 @@ async def query_nostr_relay_for_providers(
|
||||
elif data[0] == "NOTICE":
|
||||
try:
|
||||
msg = str(data[1])
|
||||
if len(msg) > 200:
|
||||
msg = msg[:200] + "..."
|
||||
logger.debug(f"Relay notice: {msg}")
|
||||
except Exception:
|
||||
logger.debug("Relay notice received")
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NIP-91: Routstr Provider Discoverability Implementation
|
||||
Listing: Routstr Provider Discoverability Implementation
|
||||
Automatically announces this Routstr proxy instance to Nostr relays.
|
||||
"""
|
||||
|
||||
@@ -18,15 +18,15 @@ from nostr.key import PrivateKey
|
||||
from nostr.message_type import ClientMessageType
|
||||
from nostr.relay_manager import RelayManager
|
||||
|
||||
from .core import get_logger
|
||||
from .core.settings import settings
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_app_version() -> str | None:
|
||||
try:
|
||||
from .core.main import __version__ as imported_version
|
||||
from ..core.main import __version__ as imported_version
|
||||
|
||||
return imported_version
|
||||
except Exception:
|
||||
@@ -71,7 +71,7 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_nip91_event(
|
||||
def create_listing_event(
|
||||
private_key_hex: str,
|
||||
provider_id: str,
|
||||
endpoint_urls: list[str],
|
||||
@@ -80,7 +80,7 @@ def create_nip91_event(
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a NIP-91 compliant provider announcement event (kind:38421).
|
||||
Create a listing provider announcement event (kind:38421).
|
||||
|
||||
Args:
|
||||
private_key_hex: 32-byte hex private key for signing
|
||||
@@ -164,14 +164,14 @@ def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def query_nip91_events(
|
||||
async def query_listing_events(
|
||||
relay_url: str,
|
||||
pubkey: str,
|
||||
provider_id: str | None = None,
|
||||
timeout: int = 30,
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""
|
||||
Query a Nostr relay for NIP-91 provider announcements (kind:38421) via nostr library.
|
||||
Query a Nostr relay for listing provider announcements (kind:38421) via nostr library.
|
||||
|
||||
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
|
||||
succeeded without transport-level errors.
|
||||
@@ -188,7 +188,7 @@ async def query_nip91_events(
|
||||
|
||||
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
|
||||
filters = Filters([flt])
|
||||
sub_id = f"nip91_{int(time.time())}"
|
||||
sub_id = f"routstr_listing_{int(time.time())}"
|
||||
rm.add_subscription(sub_id, filters)
|
||||
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
|
||||
req.extend(filters.to_json_array())
|
||||
@@ -294,7 +294,7 @@ async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) ->
|
||||
|
||||
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
|
||||
events, _ok = await query_listing_events(relay_url, public_key_hex, None)
|
||||
return events
|
||||
except Exception:
|
||||
return []
|
||||
@@ -330,7 +330,7 @@ async def publish_to_relay(
|
||||
timeout: int = 30,
|
||||
) -> bool:
|
||||
"""
|
||||
Publish a NIP-91 event to a nostr relay via nostr library.
|
||||
Publish a listing event to a nostr relay via nostr library.
|
||||
"""
|
||||
|
||||
def _sync_publish() -> bool:
|
||||
@@ -341,7 +341,7 @@ async def publish_to_relay(
|
||||
time.sleep(1.0)
|
||||
# Publish the event as-is via publish_message to preserve signature
|
||||
rm.publish_message(json.dumps(["EVENT", event]))
|
||||
logger.debug(f"Sent NIP-91 event {event.get('id', '')} to {relay_url}")
|
||||
logger.debug(f"Sent listing event {event.get('id', '')} to {relay_url}")
|
||||
time.sleep(1.0)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -364,13 +364,13 @@ async def announce_provider() -> None:
|
||||
# Check for NSEC in environment (use NSEC only)
|
||||
nsec = settings.nsec
|
||||
if not nsec:
|
||||
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
|
||||
logger.info("Nostr private key not found (NSEC), skipping listing announcement")
|
||||
return
|
||||
|
||||
# Convert NSEC to keypair
|
||||
keypair = nsec_to_keypair(nsec)
|
||||
if not keypair:
|
||||
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
|
||||
logger.error("Failed to parse NSEC, skipping listing announcement")
|
||||
return
|
||||
|
||||
private_key_hex, public_key_hex = keypair
|
||||
@@ -409,7 +409,7 @@ async def announce_provider() -> None:
|
||||
|
||||
if not endpoint_urls:
|
||||
logger.warning(
|
||||
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
|
||||
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping listing publish."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -434,7 +434,7 @@ async def announce_provider() -> None:
|
||||
|
||||
# Create the candidate event that we would publish
|
||||
version_str = get_app_version()
|
||||
candidate_event = create_nip91_event(
|
||||
candidate_event = create_listing_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
@@ -474,7 +474,7 @@ async def announce_provider() -> None:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||
continue
|
||||
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
|
||||
events, ok = await query_listing_events(relay_url, public_key_hex, provider_id)
|
||||
if ok:
|
||||
_register_success(relay_url)
|
||||
existing_events.extend(events)
|
||||
@@ -489,7 +489,7 @@ async def announce_provider() -> None:
|
||||
|
||||
if not all_match:
|
||||
logger.debug(
|
||||
"No matching NIP-91 announcement found or differences detected; publishing update"
|
||||
"No matching listing announcement found or differences detected; publishing update"
|
||||
)
|
||||
success_count = 0
|
||||
for relay_url in relay_urls:
|
||||
@@ -502,11 +502,11 @@ async def announce_provider() -> None:
|
||||
else:
|
||||
_register_failure(relay_url)
|
||||
logger.info(
|
||||
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
|
||||
f"Published listing announcement to {success_count}/{len(relay_urls)} relays"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Matching NIP-91 announcement already present; skipping publish on startup"
|
||||
"Matching listing announcement already present; skipping publish on startup"
|
||||
)
|
||||
|
||||
# Re-announce periodically (every 24 hours)
|
||||
@@ -518,7 +518,7 @@ async def announce_provider() -> None:
|
||||
|
||||
# Build fresh candidate event for comparison
|
||||
version_str = get_app_version()
|
||||
candidate_event = create_nip91_event(
|
||||
candidate_event = create_listing_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
@@ -533,7 +533,7 @@ async def announce_provider() -> None:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||
continue
|
||||
events, ok = await query_nip91_events(
|
||||
events, ok = await query_listing_events(
|
||||
relay_url, public_key_hex, provider_id
|
||||
)
|
||||
if ok:
|
||||
@@ -549,7 +549,7 @@ async def announce_provider() -> None:
|
||||
|
||||
if all_match:
|
||||
logger.debug(
|
||||
"Matching NIP-91 announcement already present; skipping periodic re-announce"
|
||||
"Matching listing announcement already present; skipping periodic re-announce"
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -567,8 +567,8 @@ async def announce_provider() -> None:
|
||||
_register_failure(relay_url)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("NIP-91 announcement task cancelled")
|
||||
logger.info("Listing announcement task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Error in NIP-91 announcement loop: {type(e).__name__}")
|
||||
logger.debug(f"Error in listing announcement loop: {type(e).__name__}")
|
||||
# Continue running despite errors
|
||||
@@ -169,9 +169,32 @@ async def calculate_discounted_max_cost(
|
||||
|
||||
tol = settings.tolerance_percentage
|
||||
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
||||
|
||||
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
||||
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
|
||||
|
||||
if model_obj:
|
||||
prompt_token_limit: int | None = None
|
||||
if model_obj.top_provider and (
|
||||
model_obj.top_provider.context_length
|
||||
or model_obj.top_provider.max_completion_tokens
|
||||
):
|
||||
cl = model_obj.top_provider.context_length
|
||||
mct = model_obj.top_provider.max_completion_tokens
|
||||
if cl and mct:
|
||||
prompt_token_limit = max(0, cl - mct)
|
||||
elif cl:
|
||||
prompt_token_limit = cl
|
||||
elif mct:
|
||||
prompt_token_limit = 0
|
||||
elif model_obj.context_length:
|
||||
prompt_token_limit = model_obj.context_length
|
||||
|
||||
if prompt_token_limit is not None:
|
||||
max_prompt_allowed_sats = (
|
||||
prompt_token_limit * model_pricing.prompt * tol_factor
|
||||
)
|
||||
|
||||
adjusted = max_cost_for_model
|
||||
|
||||
if messages := body.get("messages"):
|
||||
|
||||
@@ -25,82 +25,6 @@ class LNURLError(Exception):
|
||||
"""LNURL related errors."""
|
||||
|
||||
|
||||
def parse_lightning_invoice_amount(invoice: str, currency: str = "sat") -> int:
|
||||
"""Parse Lightning invoice (BOLT-11) to extract amount in specified currency units.
|
||||
|
||||
Args:
|
||||
invoice: BOLT-11 Lightning invoice string
|
||||
currency: Target currency unit ("sat" or "msat")
|
||||
|
||||
Returns:
|
||||
Amount in the specified currency unit
|
||||
|
||||
Raises:
|
||||
LNURLError: If invoice format is invalid or amount cannot be parsed
|
||||
"""
|
||||
invoice = invoice.lower().strip()
|
||||
|
||||
if not invoice.startswith("ln"):
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
# Find the network part (bc, tb, etc.)
|
||||
network_start = 2
|
||||
while network_start < len(invoice) and invoice[network_start] not in "0123456789":
|
||||
network_start += 1
|
||||
|
||||
if network_start >= len(invoice):
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
# Parse amount and multiplier
|
||||
amount_str = ""
|
||||
multiplier = ""
|
||||
i = network_start
|
||||
|
||||
# Extract numeric part
|
||||
while i < len(invoice) and invoice[i].isdigit():
|
||||
amount_str += invoice[i]
|
||||
i += 1
|
||||
|
||||
# Extract multiplier if present
|
||||
if i < len(invoice) and invoice[i] in "munp":
|
||||
multiplier = invoice[i]
|
||||
i += 1
|
||||
|
||||
# Check if we have the required "1" separator
|
||||
if i >= len(invoice) or invoice[i] != "1":
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
if not amount_str:
|
||||
raise LNURLError("Lightning invoice amount not specified")
|
||||
|
||||
# Convert to base units
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
raise LNURLError("Invalid Lightning invoice amount")
|
||||
|
||||
# Apply multiplier to get millisatoshis
|
||||
if multiplier == "m": # milli = 10^-3
|
||||
amount_msat = amount * 100_000_000 # amount is in BTC * 10^-3
|
||||
elif multiplier == "u": # micro = 10^-6
|
||||
amount_msat = amount * 100_000 # amount is in BTC * 10^-6
|
||||
elif multiplier == "n": # nano = 10^-9
|
||||
amount_msat = amount * 100 # amount is in BTC * 10^-9
|
||||
elif multiplier == "p": # pico = 10^-12
|
||||
amount_msat = amount // 10 # amount is in BTC * 10^-12
|
||||
else:
|
||||
# No multiplier means the amount is in BTC
|
||||
amount_msat = amount * 100_000_000_000 # Convert BTC to msat
|
||||
|
||||
# Convert to target currency unit
|
||||
if currency == "msat":
|
||||
return amount_msat
|
||||
elif currency == "sat":
|
||||
return amount_msat // 1000
|
||||
else:
|
||||
raise LNURLError(f"Unsupported currency for Lightning: {currency}")
|
||||
|
||||
|
||||
async def decode_lnurl(lnurl: str) -> str:
|
||||
"""Decode LNURL to get the actual URL.
|
||||
|
||||
|
||||
@@ -143,14 +143,6 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
return []
|
||||
|
||||
|
||||
def is_openrouter_upstream() -> bool:
|
||||
try:
|
||||
base = (settings.upstream_base_url or "").strip().rstrip("/")
|
||||
except Exception:
|
||||
return False
|
||||
return base.lower() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
@@ -203,29 +195,6 @@ def _row_to_model(
|
||||
return model
|
||||
|
||||
|
||||
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
|
||||
return {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"created": model.created,
|
||||
"description": model.description,
|
||||
"context_length": model.context_length,
|
||||
"architecture": json.dumps(model.architecture.dict()),
|
||||
"pricing": json.dumps(model.pricing.dict()),
|
||||
"sats_pricing": json.dumps(model.sats_pricing.dict())
|
||||
if model.sats_pricing
|
||||
else None,
|
||||
"per_request_limits": json.dumps(model.per_request_limits)
|
||||
if model.per_request_limits is not None
|
||||
else None,
|
||||
"top_provider": json.dumps(model.top_provider.dict())
|
||||
if model.top_provider is not None
|
||||
else None,
|
||||
"enabled": model.enabled,
|
||||
"upstream_provider_id": model.upstream_provider_id,
|
||||
}
|
||||
|
||||
|
||||
async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
@@ -262,21 +231,6 @@ async def list_models(
|
||||
]
|
||||
|
||||
|
||||
async def get_model_by_id(
|
||||
model_id: str, provider_id: int, session: AsyncSession
|
||||
) -> Model | None:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
if not row or not row.enabled:
|
||||
return None
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider or not provider.enabled:
|
||||
return None
|
||||
provider_fee = provider.provider_fee if provider else 1.01
|
||||
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
||||
|
||||
|
||||
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
"""Calculate max costs in USD based on model context/token limits.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from .core.db import (
|
||||
get_session,
|
||||
)
|
||||
from .core.exceptions import UpstreamError
|
||||
from .core.settings import settings
|
||||
from .payment.helpers import (
|
||||
calculate_discounted_max_cost,
|
||||
check_token_balance,
|
||||
@@ -176,6 +177,9 @@ async def proxy(
|
||||
max_cost_for_model = await calculate_discounted_max_cost(
|
||||
_max_cost_for_model, request_body_dict, model_obj=model_obj
|
||||
)
|
||||
# Ensure max_cost_for_model is at least the minimum allowed request cost
|
||||
max_cost_for_model = max(max_cost_for_model, settings.min_request_msat)
|
||||
|
||||
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
@@ -206,7 +210,9 @@ async def proxy(
|
||||
)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
key = await get_bearer_token_key(headers, path, session, auth)
|
||||
key = await get_bearer_token_key(
|
||||
headers, path, session, auth, max_cost_for_model
|
||||
)
|
||||
|
||||
else:
|
||||
if request.method not in ["GET"]:
|
||||
@@ -381,7 +387,7 @@ async def proxy(
|
||||
|
||||
|
||||
async def get_bearer_token_key(
|
||||
headers: dict, path: str, session: AsyncSession, auth: str
|
||||
headers: dict, path: str, session: AsyncSession, auth: str, min_cost: int = 0
|
||||
) -> ApiKey:
|
||||
"""Handle bearer token authentication proxy requests."""
|
||||
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
|
||||
@@ -397,6 +403,7 @@ async def get_bearer_token_key(
|
||||
"bearer_key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
"min_cost": min_cost,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -435,6 +442,7 @@ async def get_bearer_token_key(
|
||||
session,
|
||||
refund_address,
|
||||
key_expiry_time, # type: ignore
|
||||
min_cost=min_cost,
|
||||
)
|
||||
logger.info(
|
||||
"Bearer token validated successfully",
|
||||
|
||||
@@ -5,21 +5,18 @@ import json
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Mapping
|
||||
from typing import Mapping
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from ..auth import adjust_payment_for_tokens, revert_pay_for_request
|
||||
from ..core import get_logger
|
||||
from ..core.db import ApiKey, AsyncSession, create_session
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session
|
||||
from ..core.exceptions import UpstreamError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
from ..payment.cost_calculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
@@ -32,6 +29,7 @@ from ..payment.models import (
|
||||
Pricing,
|
||||
_calculate_usd_max_costs,
|
||||
_update_model_sats_pricing,
|
||||
list_models,
|
||||
)
|
||||
from ..payment.price import sats_usd_price
|
||||
from ..wallet import recieve_token, send_token
|
||||
@@ -427,7 +425,11 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
async def handle_streaming_chat_completion(
|
||||
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int
|
||||
self,
|
||||
response: httpx.Response,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> StreamingResponse:
|
||||
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
|
||||
|
||||
@@ -521,10 +523,17 @@ class BaseUpstreamProvider:
|
||||
session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
remaining_balance_msats = fresh_key.balance
|
||||
# Merge cost into usage
|
||||
usage_chunk_data["usage"]["cost"] = cost_data.get(
|
||||
"total_usd", 0.0
|
||||
)
|
||||
usage_chunk_data["usage"]["cost_sats"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
usage_chunk_data["usage"]["remaining_balance_msats"] = (
|
||||
remaining_balance_msats
|
||||
)
|
||||
# Keep detailed cost in metadata
|
||||
usage_chunk_data["metadata"] = usage_chunk_data.get(
|
||||
"metadata", {}
|
||||
@@ -532,9 +541,22 @@ class BaseUpstreamProvider:
|
||||
usage_chunk_data["metadata"]["routstr"] = {
|
||||
"cost": cost_data
|
||||
}
|
||||
usage_chunk_data["metadata"]["routstr"]["cost"][
|
||||
"sats_cost"
|
||||
] = cost_data.get("total_msats", 0) // 1000
|
||||
usage_chunk_data["metadata"]["routstr"]["cost"][
|
||||
"remaining_balance_msats"
|
||||
] = remaining_balance_msats
|
||||
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||
usage_finalized = True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error during usage finalization",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
# Fallback: yield original usage chunk if adjustment fails
|
||||
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||
|
||||
@@ -555,7 +577,9 @@ class BaseUpstreamProvider:
|
||||
raise
|
||||
finally:
|
||||
if not usage_finalized:
|
||||
await finalize_db_only()
|
||||
# Create a background task to ensure finalization happens
|
||||
# even if the generator is closed early
|
||||
background_tasks.add_task(finalize_db_only)
|
||||
|
||||
# Remove inaccurate encoding headers from upstream response
|
||||
response_headers = dict(response.headers)
|
||||
@@ -613,14 +637,31 @@ class BaseUpstreamProvider:
|
||||
key, response_json, session, deducted_max_cost
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
remaining_balance_msats = key.balance
|
||||
|
||||
# Merge cost into usage for OpenCode
|
||||
if "usage" in response_json:
|
||||
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
|
||||
response_json["usage"]["cost_sats"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
response_json["usage"]["remaining_balance_msats"] = (
|
||||
remaining_balance_msats
|
||||
)
|
||||
|
||||
# Keep detailed cost
|
||||
response_json["metadata"] = response_json.get("metadata", {})
|
||||
response_json["metadata"]["routstr"] = {"cost": cost_data}
|
||||
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
|
||||
remaining_balance_msats
|
||||
)
|
||||
response_json["cost"] = cost_data
|
||||
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
|
||||
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
|
||||
|
||||
logger.info(
|
||||
"Payment adjustment completed for non-streaming",
|
||||
@@ -790,6 +831,7 @@ class BaseUpstreamProvider:
|
||||
session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
remaining_balance_msats = fresh_key.balance
|
||||
# Merge cost into usage chunk
|
||||
if (
|
||||
"response" in usage_chunk_data
|
||||
@@ -798,10 +840,22 @@ class BaseUpstreamProvider:
|
||||
usage_chunk_data["response"]["usage"]["cost"] = (
|
||||
cost_data.get("total_usd", 0.0)
|
||||
)
|
||||
usage_chunk_data["response"]["usage"][
|
||||
"cost_sats"
|
||||
] = cost_data.get("total_msats", 0) // 1000
|
||||
usage_chunk_data["response"]["usage"][
|
||||
"remaining_balance_msats"
|
||||
] = remaining_balance_msats
|
||||
elif "usage" in usage_chunk_data:
|
||||
usage_chunk_data["usage"]["cost"] = cost_data.get(
|
||||
"total_usd", 0.0
|
||||
)
|
||||
usage_chunk_data["usage"]["cost_sats"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
usage_chunk_data["usage"][
|
||||
"remaining_balance_msats"
|
||||
] = remaining_balance_msats
|
||||
|
||||
# Keep detailed cost in metadata
|
||||
usage_chunk_data["metadata"] = usage_chunk_data.get(
|
||||
@@ -810,6 +864,12 @@ class BaseUpstreamProvider:
|
||||
usage_chunk_data["metadata"]["routstr"] = {
|
||||
"cost": cost_data
|
||||
}
|
||||
usage_chunk_data["metadata"]["routstr"]["cost"][
|
||||
"sats_cost"
|
||||
] = cost_data.get("total_msats", 0) // 1000
|
||||
usage_chunk_data["metadata"]["routstr"]["cost"][
|
||||
"remaining_balance_msats"
|
||||
] = remaining_balance_msats
|
||||
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||
usage_finalized = True
|
||||
except Exception:
|
||||
@@ -894,14 +954,31 @@ class BaseUpstreamProvider:
|
||||
key, response_json, session, deducted_max_cost
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
remaining_balance_msats = key.balance
|
||||
|
||||
# Merge cost into usage for OpenCode
|
||||
if "usage" in response_json:
|
||||
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
|
||||
response_json["usage"]["cost_sats"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
response_json["usage"]["remaining_balance_msats"] = (
|
||||
remaining_balance_msats
|
||||
)
|
||||
|
||||
# Keep detailed cost
|
||||
response_json["metadata"] = response_json.get("metadata", {})
|
||||
response_json["metadata"]["routstr"] = {"cost": cost_data}
|
||||
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
|
||||
remaining_balance_msats
|
||||
)
|
||||
response_json["cost"] = cost_data
|
||||
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
|
||||
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
|
||||
|
||||
logger.info(
|
||||
"Payment adjustment completed for non-streaming Responses API",
|
||||
@@ -1136,12 +1213,12 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
if is_streaming and response.status_code == 200:
|
||||
result = await self.handle_streaming_chat_completion(
|
||||
response, key, max_cost_for_model
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
background_tasks.add_task(client.aclose)
|
||||
result = await self.handle_streaming_chat_completion(
|
||||
response, key, max_cost_for_model, background_tasks
|
||||
)
|
||||
result.background = background_tasks
|
||||
return result
|
||||
|
||||
@@ -3010,18 +3087,45 @@ class BaseUpstreamProvider:
|
||||
async def refresh_models_cache(self) -> None:
|
||||
"""Refresh the in-memory models cache from upstream API."""
|
||||
try:
|
||||
models = await self.fetch_models()
|
||||
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
||||
async with create_session() as session:
|
||||
stmt = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == self.base_url,
|
||||
UpstreamProviderRow.api_key == self.api_key
|
||||
)
|
||||
result = await session.exec(stmt)
|
||||
|
||||
# .first() returns the object or None if not found
|
||||
provider = result.first()
|
||||
if not provider or not provider.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
self._models_cache = [
|
||||
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
|
||||
]
|
||||
except Exception:
|
||||
self._models_cache = models_with_fees
|
||||
db_models = await list_models(
|
||||
session=session,
|
||||
upstream_id=provider.id,
|
||||
include_disabled=False,
|
||||
apply_fees=False,
|
||||
)
|
||||
db_model_ids: set[str] = {model.id for model in db_models}
|
||||
models = await self.fetch_models()
|
||||
model_ids = [model.id for model in models]
|
||||
diff = set(db_model_ids) - set(model_ids)
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
for db_model_id in diff:
|
||||
found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id))
|
||||
models.append(found_db_model)
|
||||
|
||||
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
||||
print([mode.id for mode in models_with_fees])
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
self._models_cache = [
|
||||
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
|
||||
]
|
||||
except Exception:
|
||||
self._models_cache = models_with_fees
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -259,7 +259,15 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key, openai_format_response, session, max_cost_for_model
|
||||
)
|
||||
await session.refresh(key)
|
||||
remaining_balance_msats = key.balance
|
||||
openai_format_response["cost"] = cost_data
|
||||
openai_format_response["cost"]["sats_cost"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
openai_format_response["cost"]["remaining_balance_msats"] = (
|
||||
remaining_balance_msats
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Gemini non-streaming payment completed",
|
||||
|
||||
97
tests/integration/test_child_keys_api.py
Normal file
97
tests/integration/test_child_keys_api.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_returns_child_keys(
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
"""Test that GET /v1/wallet/info returns child keys for a parent key"""
|
||||
|
||||
# 1. Get parent info to find its hashed_key
|
||||
response = await authenticated_client.get("/v1/wallet/info")
|
||||
assert response.status_code == 200
|
||||
parent_data = response.json()
|
||||
parent_data["api_key"]
|
||||
|
||||
# 2. Create child keys for this parent
|
||||
# We need to use the parent's authentication for this
|
||||
child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"}
|
||||
create_response = await authenticated_client.post(
|
||||
"/v1/wallet/child-key", json=child_payload
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
create_data = create_response.json()
|
||||
child_keys = create_data["api_keys"]
|
||||
assert len(child_keys) == 2
|
||||
|
||||
# 3. Call /info again and check for child_keys
|
||||
info_response = await authenticated_client.get("/v1/wallet/info")
|
||||
assert info_response.status_code == 200
|
||||
info_data = info_response.json()
|
||||
|
||||
assert "child_keys" in info_data
|
||||
assert len(info_data["child_keys"]) == 2
|
||||
|
||||
# Verify child key details
|
||||
for ck in info_data["child_keys"]:
|
||||
assert ck["api_key"] in child_keys
|
||||
assert ck["balance_limit"] == 1000
|
||||
assert ck["balance_limit_reset"] == "daily"
|
||||
assert "total_spent" in ck
|
||||
assert "total_requests" in ck
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_child_key_no_child_keys(
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
"""Test that GET /v1/wallet/info for a child key does NOT return child_keys"""
|
||||
|
||||
# 1. Create a child key
|
||||
child_payload = {"count": 1}
|
||||
create_response = await authenticated_client.post(
|
||||
"/v1/wallet/child-key", json=child_payload
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
child_key = create_response.json()["api_keys"][0]
|
||||
|
||||
# 2. Use the child key to get its info
|
||||
integration_client.headers["Authorization"] = f"Bearer {child_key}"
|
||||
info_response = await integration_client.get("/v1/wallet/info")
|
||||
assert info_response.status_code == 200
|
||||
info_data = info_response.json()
|
||||
|
||||
assert info_data["is_child"] is True
|
||||
assert "child_keys" not in info_data
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_info_root_returns_child_keys(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test that GET / returns child keys for a parent key (root endpoint)"""
|
||||
|
||||
# 1. Create a child key
|
||||
child_payload = {"count": 1}
|
||||
await authenticated_client.post("/v1/wallet/child-key", json=child_payload)
|
||||
|
||||
# 2. Call root endpoint /v1/balance/
|
||||
# Note: routstr/balance.py defines router = APIRouter()
|
||||
# and it is included in balance_router with prefix /v1/balance
|
||||
# The endpoint is @router.get("/")
|
||||
response = await authenticated_client.get("/v1/balance/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "child_keys" in data
|
||||
assert len(data["child_keys"]) >= 1
|
||||
142
tests/integration/test_key_logic.py
Normal file
142
tests/integration/test_key_logic.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import pay_for_request
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_validity_date(integration_session: AsyncSession) -> None:
|
||||
# 1. Create a key that is expired
|
||||
expired_time = int(time.time()) - 3600
|
||||
key = ApiKey(hashed_key="expired_key", balance=1000, validity_date=expired_time)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# 2. Try to pay for a request - should fail
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await pay_for_request(key, 100, integration_session)
|
||||
assert "expired" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_balance_limit(integration_session: AsyncSession) -> None:
|
||||
# 1. Create a key with a balance limit
|
||||
key = ApiKey(
|
||||
hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# 2. Try to pay for a request that exceeds the limit
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await pay_for_request(key, 100, integration_session)
|
||||
assert "limit exceeded" in str(excinfo.value).lower()
|
||||
|
||||
# 3. Try to pay for a request that fits
|
||||
await pay_for_request(key, 50, integration_session)
|
||||
await integration_session.refresh(key)
|
||||
# Note: total_spent is updated in adjust_payment_for_tokens,
|
||||
# but pay_for_request checks it.
|
||||
# In our current logic, pay_for_request checks (total_spent + cost) > balance_limit.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None:
|
||||
# 1. Create a key with a daily reset policy and old reset date
|
||||
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
|
||||
key = ApiKey(
|
||||
hashed_key="daily_reset_key",
|
||||
balance=10000,
|
||||
balance_limit=1000,
|
||||
balance_limit_reset="daily",
|
||||
balance_limit_reset_date=yesterday,
|
||||
total_spent=900,
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# 2. Pay for a request - should trigger reset first because it's a new day
|
||||
# Request is 200, total_spent is 900. 900+200 > 1000,
|
||||
# but reset should happen making total_spent 0, then 0+200 < 1000.
|
||||
await pay_for_request(key, 200, integration_session)
|
||||
|
||||
await integration_session.refresh(key)
|
||||
assert key.total_spent == 0 # Reset in pay_for_request happens before charging
|
||||
# Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests,
|
||||
# but total_spent is updated in adjust_payment_for_tokens.
|
||||
# However, the reset logic sets total_spent to 0.
|
||||
assert key.balance_limit_reset_date is not None
|
||||
assert key.balance_limit_reset_date > yesterday
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None:
|
||||
# 1. Create multiple keys needing reset
|
||||
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
|
||||
key1 = ApiKey(
|
||||
hashed_key="job_reset_key_1",
|
||||
balance=1000,
|
||||
balance_limit=1000,
|
||||
balance_limit_reset="daily",
|
||||
balance_limit_reset_date=yesterday,
|
||||
total_spent=500,
|
||||
)
|
||||
key2 = ApiKey(
|
||||
hashed_key="job_reset_key_2",
|
||||
balance=1000,
|
||||
balance_limit=1000,
|
||||
balance_limit_reset="daily",
|
||||
balance_limit_reset_date=yesterday,
|
||||
total_spent=800,
|
||||
)
|
||||
integration_session.add(key1)
|
||||
integration_session.add(key2)
|
||||
await integration_session.commit()
|
||||
|
||||
# 2. Run the periodic reset logic manually (mocking the background task loop)
|
||||
# We can't easily run the actual loop because it has a sleep,
|
||||
# but we can test the logic inside.
|
||||
|
||||
# Implementation of periodic_key_reset logic for testing:
|
||||
stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) # noqa: E711
|
||||
keys = (await integration_session.exec(stmt)).all()
|
||||
now = int(time.time())
|
||||
for k in keys:
|
||||
if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]:
|
||||
k.total_spent = 0
|
||||
k.balance_limit_reset_date = now
|
||||
integration_session.add(k)
|
||||
await integration_session.commit()
|
||||
|
||||
# 3. Verify resets
|
||||
await integration_session.refresh(key1)
|
||||
await integration_session.refresh(key2)
|
||||
assert key1.total_spent == 0
|
||||
assert key2.total_spent == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
|
||||
# This requires mocking the router call or testing the logic in balance.py
|
||||
from routstr.balance import ApiKey
|
||||
|
||||
key = ApiKey(hashed_key="refund_test_key", balance=1000, reserved_balance=100)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Logic from refund_wallet_endpoint:
|
||||
key.balance = 0
|
||||
key.reserved_balance = 0
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Verify key still exists
|
||||
fetched_key = await integration_session.get(ApiKey, "refund_test_key")
|
||||
assert fetched_key is not None
|
||||
assert fetched_key.balance == 0
|
||||
assert fetched_key.reserved_balance == 0
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from routstr.discovery import _PROVIDERS_CACHE
|
||||
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
||||
|
||||
from .utils import ResponseValidator
|
||||
|
||||
@@ -71,9 +71,10 @@ async def test_providers_endpoint_default_response(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
# Configure mock to return appropriate responses
|
||||
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
|
||||
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
|
||||
@@ -135,9 +136,10 @@ async def test_providers_endpoint_with_include_json(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"status_code": 200,
|
||||
"json": mock_provider_response,
|
||||
@@ -209,9 +211,10 @@ async def test_providers_data_structure_validation(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = mock_health_response
|
||||
|
||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||
@@ -256,7 +259,8 @@ async def test_providers_endpoint_no_providers_found(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -317,10 +321,11 @@ async def test_providers_endpoint_offline_providers(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch(
|
||||
"routstr.discovery.fetch_provider_health",
|
||||
"routstr.nostr.discovery.fetch_provider_health",
|
||||
side_effect=mock_fetch_provider_health,
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||
@@ -386,9 +391,10 @@ async def test_providers_endpoint_duplicate_urls(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"status_code": 200,
|
||||
"endpoint": "root",
|
||||
@@ -425,7 +431,8 @@ async def test_providers_endpoint_nostr_relay_failures(
|
||||
raise Exception("Connection to relay failed")
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
side_effect=failing_query,
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -463,9 +470,10 @@ async def test_providers_endpoint_malformed_urls(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
@@ -495,9 +503,10 @@ async def test_providers_endpoint_response_format(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test default format
|
||||
@@ -545,9 +554,10 @@ async def test_providers_endpoint_concurrent_requests(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Create concurrent requests
|
||||
@@ -587,9 +597,10 @@ async def test_providers_endpoint_parameter_validation(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test various parameter values
|
||||
@@ -639,9 +650,10 @@ async def test_no_database_changes_during_provider_operations(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers",
|
||||
return_value=mock_events,
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Make multiple requests with different parameters
|
||||
|
||||
@@ -65,9 +65,10 @@ async def test_full_balance_refund_returns_cashu_token(
|
||||
except Exception as e:
|
||||
pytest.fail(f"Invalid Cashu token format: {e}")
|
||||
|
||||
# Try to use the API key - should fail since it's been deleted
|
||||
# Try to use the API key - should still work but have 0 balance
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 200
|
||||
assert response.json()["balance"] == 0
|
||||
|
||||
# The refund token has been validated above by decoding it
|
||||
# The API key deletion has been verified by the 401 response
|
||||
@@ -261,17 +262,16 @@ async def test_database_state_after_refund(
|
||||
response = await authenticated_client.post("/v1/wallet/refund")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify key is deleted after refund
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
# Refresh the key to get the updated balance from the database
|
||||
await integration_session.refresh(key_before)
|
||||
|
||||
# Count total keys to ensure only the specific one was deleted
|
||||
# Verify key balance is 0 after refund
|
||||
assert key_before.balance == 0
|
||||
|
||||
# Count total keys to ensure it wasn't deleted
|
||||
result = await integration_session.execute(select(ApiKey))
|
||||
remaining_keys = result.scalars().all()
|
||||
# Should have no keys left (assuming clean test environment)
|
||||
assert len(remaining_keys) == 0
|
||||
assert len(remaining_keys) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -388,9 +388,10 @@ async def test_refund_during_active_usage(
|
||||
# Refund should succeed
|
||||
assert refund_response.status_code == 200
|
||||
|
||||
# Further usage should fail
|
||||
# Further usage should return 200 but with 0 balance
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 200
|
||||
assert response.json()["balance"] == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -535,6 +536,3 @@ async def test_refund_with_expired_key(
|
||||
# Should still allow manual refund
|
||||
assert response.status_code == 200
|
||||
assert response.json()["recipient"] == "expired@ln.address"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Unit tests for model row payload conversion.
|
||||
|
||||
This module tests that _model_to_row_payload correctly serializes model data
|
||||
for database storage. Pricing is stored as-is without fee application.
|
||||
Fees are now applied per-provider when reading from the database.
|
||||
|
||||
Key behaviors tested:
|
||||
1. Pricing is stored as-is without fee application
|
||||
2. All model fields are correctly serialized to JSON
|
||||
3. Optional fields are handled correctly (None values)
|
||||
4. Pricing structure is preserved
|
||||
5. Original model objects are not mutated
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
# Set required env vars before importing
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.payment.models import ( # noqa: E402
|
||||
Architecture,
|
||||
Model,
|
||||
Pricing,
|
||||
_model_to_row_payload,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_architecture() -> Architecture:
|
||||
"""Provide standard architecture for test models."""
|
||||
return Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="gpt",
|
||||
instruct_type="chat",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def standard_pricing() -> Pricing:
|
||||
"""Provide standard USD pricing with known values for testing."""
|
||||
return Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.002,
|
||||
request=0.01,
|
||||
image=0.05,
|
||||
web_search=0.03,
|
||||
internal_reasoning=0.015,
|
||||
max_prompt_cost=10.0,
|
||||
max_completion_cost=20.0,
|
||||
max_cost=30.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def standard_model(base_architecture: Architecture, standard_pricing: Pricing) -> Model:
|
||||
"""Create a standard test model with known pricing."""
|
||||
return Model(
|
||||
id="test-model-standard",
|
||||
name="Test Model Standard",
|
||||
created=1234567890,
|
||||
description="A standard test model",
|
||||
context_length=8192,
|
||||
architecture=base_architecture,
|
||||
pricing=standard_pricing,
|
||||
)
|
||||
|
||||
|
||||
def test_pricing_stored_without_fees(standard_model: Model) -> None:
|
||||
"""Verify pricing is stored as-is without any fee application."""
|
||||
payload = _model_to_row_payload(standard_model)
|
||||
pricing_str = payload["pricing"]
|
||||
assert isinstance(pricing_str, str)
|
||||
pricing = json.loads(pricing_str)
|
||||
|
||||
assert pricing["prompt"] == pytest.approx(0.001, rel=1e-9)
|
||||
assert pricing["completion"] == pytest.approx(0.002, rel=1e-9)
|
||||
assert pricing["request"] == pytest.approx(0.01, rel=1e-9)
|
||||
assert pricing["image"] == pytest.approx(0.05, rel=1e-9)
|
||||
assert pricing["web_search"] == pytest.approx(0.03, rel=1e-9)
|
||||
assert pricing["internal_reasoning"] == pytest.approx(0.015, rel=1e-9)
|
||||
assert pricing["max_prompt_cost"] == pytest.approx(10.0, rel=1e-9)
|
||||
assert pricing["max_completion_cost"] == pytest.approx(20.0, rel=1e-9)
|
||||
assert pricing["max_cost"] == pytest.approx(30.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_zero_value_pricing_fields(base_architecture: Architecture) -> None:
|
||||
"""Verify that zero-value pricing fields are stored correctly."""
|
||||
zero_pricing = Pricing(
|
||||
prompt=0.0,
|
||||
completion=0.0,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
max_prompt_cost=0.0,
|
||||
max_completion_cost=0.0,
|
||||
max_cost=0.0,
|
||||
)
|
||||
|
||||
model = Model(
|
||||
id="test-model-zero",
|
||||
name="Test Model Zero",
|
||||
created=1234567890,
|
||||
description="A model with zero pricing",
|
||||
context_length=8192,
|
||||
architecture=base_architecture,
|
||||
pricing=zero_pricing,
|
||||
)
|
||||
|
||||
payload = _model_to_row_payload(model)
|
||||
pricing_str = payload["pricing"]
|
||||
assert isinstance(pricing_str, str)
|
||||
pricing = json.loads(pricing_str)
|
||||
|
||||
assert pricing["prompt"] == pytest.approx(0.0, rel=1e-9)
|
||||
assert pricing["completion"] == pytest.approx(0.0, rel=1e-9)
|
||||
assert pricing["request"] == pytest.approx(0.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_payload_structure_unchanged(standard_model: Model) -> None:
|
||||
"""Verify that payload structure matches expectations."""
|
||||
payload = _model_to_row_payload(standard_model)
|
||||
|
||||
assert "id" in payload
|
||||
assert "name" in payload
|
||||
assert "created" in payload
|
||||
assert "description" in payload
|
||||
assert "context_length" in payload
|
||||
assert "architecture" in payload
|
||||
assert "pricing" in payload
|
||||
assert "sats_pricing" in payload
|
||||
assert "per_request_limits" in payload
|
||||
assert "top_provider" in payload
|
||||
assert "enabled" in payload
|
||||
assert "upstream_provider_id" in payload
|
||||
|
||||
assert isinstance(payload["architecture"], str)
|
||||
assert isinstance(payload["pricing"], str)
|
||||
|
||||
|
||||
def test_original_model_not_mutated(standard_model: Model) -> None:
|
||||
"""Verify that the original model object is not mutated."""
|
||||
original_prompt = standard_model.pricing.prompt
|
||||
original_completion = standard_model.pricing.completion
|
||||
|
||||
_model_to_row_payload(standard_model)
|
||||
|
||||
assert standard_model.pricing.prompt == original_prompt
|
||||
assert standard_model.pricing.completion == original_completion
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AdminModel,
|
||||
} from '@/lib/api/services/admin';
|
||||
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
|
||||
import { BatchOverrideDialog } from '@/components/BatchOverrideDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -405,6 +406,9 @@ export default function ProvidersPage() {
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
const [batchOverrideProviderId, setBatchOverrideProviderId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [formData, setFormData] = useState<CreateUpstreamProvider>({
|
||||
provider_type: 'openrouter',
|
||||
@@ -484,6 +488,25 @@ export default function ProvidersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteModelMutation = useMutation({
|
||||
mutationFn: ({
|
||||
providerId,
|
||||
modelId,
|
||||
}: {
|
||||
providerId: number;
|
||||
modelId: string;
|
||||
}) => AdminService.deleteProviderModel(providerId, modelId),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-models', variables.providerId],
|
||||
});
|
||||
toast.success('Model deleted successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to delete model: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
setIsCreatingAccount(true);
|
||||
try {
|
||||
@@ -562,6 +585,12 @@ export default function ProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = (providerId: number, modelId: string) => {
|
||||
if (confirm('Are you sure you want to delete this model?')) {
|
||||
deleteModelMutation.mutate({ providerId, modelId });
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultBaseUrl = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.default_base_url || '';
|
||||
@@ -629,6 +658,10 @@ export default function ProvidersPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchOverride = (providerId: number) => {
|
||||
setBatchOverrideProviderId(providerId);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
@@ -986,16 +1019,28 @@ export default function ProvidersPage() {
|
||||
provider's catalog.
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleAddModel(provider.id)
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add
|
||||
</Button>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleBatchOverride(provider.id)
|
||||
}
|
||||
>
|
||||
<Database className='mr-2 h-4 w-4' />
|
||||
Batch Override
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleAddModel(provider.id)
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add Custom Model
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{providerModels.db_models.length === 0 ? (
|
||||
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||
@@ -1048,6 +1093,22 @@ export default function ProvidersPage() {
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-destructive hover:text-destructive h-8 w-8'
|
||||
onClick={() =>
|
||||
handleDeleteModel(
|
||||
provider.id,
|
||||
model.id
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
deleteModelMutation.isPending
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1287,6 +1348,19 @@ export default function ProvidersPage() {
|
||||
mode={modelDialogState.mode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{batchOverrideProviderId && (
|
||||
<BatchOverrideDialog
|
||||
providerId={batchOverrideProviderId}
|
||||
isOpen={!!batchOverrideProviderId}
|
||||
onClose={() => setBatchOverrideProviderId(null)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-models', batchOverrideProviderId],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
144
ui/components/BatchOverrideDialog.tsx
Normal file
144
ui/components/BatchOverrideDialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from 'sonner';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { Loader2, Database } from 'lucide-react';
|
||||
|
||||
export interface BatchOverrideDialogProps {
|
||||
providerId: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function BatchOverrideDialog({
|
||||
providerId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: BatchOverrideDialogProps) {
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const sampleJson = {
|
||||
models: [
|
||||
{
|
||||
id: 'model-id-1',
|
||||
name: 'Model Name 1',
|
||||
description: 'Description...',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
context_length: 8192,
|
||||
architecture: {
|
||||
modality: 'text',
|
||||
input_modalities: ['text'],
|
||||
output_modalities: ['text'],
|
||||
tokenizer: '',
|
||||
instruct_type: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: 0.0,
|
||||
completion: 0.0,
|
||||
request: 0.0,
|
||||
image: 0.0,
|
||||
web_search: 0.0,
|
||||
internal_reasoning: 0.0,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handleBatchOverride = async () => {
|
||||
if (!jsonInput.trim()) {
|
||||
toast.error('Please enter JSON content');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(jsonInput);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON format');
|
||||
}
|
||||
|
||||
if (!data.models || !Array.isArray(data.models)) {
|
||||
throw new Error('JSON match follow structure: { "models": [...] }');
|
||||
}
|
||||
|
||||
const result = await AdminService.batchOverrideProviderModels(
|
||||
providerId,
|
||||
data.models
|
||||
);
|
||||
|
||||
if (result.ok) {
|
||||
toast.success(result.message || 'Batch override successful');
|
||||
onSuccess();
|
||||
onClose();
|
||||
setJsonInput('');
|
||||
} else {
|
||||
throw new Error('Batch override failed');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Batch override failed';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className='sm:max-w-[800px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Database className='h-4 w-4' />
|
||||
Batch Override Models
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste a JSON object with a "models" array containing model
|
||||
definitions. Existing models with the same ID will be updated.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='grid gap-4 py-4'>
|
||||
<Textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
placeholder={JSON.stringify(sampleJson, null, 2)}
|
||||
className='min-h-[400px] font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleBatchOverride} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Batch Override'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,26 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Key, Copy, Check, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Key,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { KeyOptions } from './key-options';
|
||||
|
||||
interface KeyConfig {
|
||||
id: string;
|
||||
count: number;
|
||||
balanceLimit: string;
|
||||
balanceLimitReset: string;
|
||||
validityDate: string;
|
||||
}
|
||||
|
||||
interface ChildKeyCreatorProps {
|
||||
baseUrl?: string;
|
||||
@@ -30,7 +48,24 @@ export function ChildKeyCreator({
|
||||
}: ChildKeyCreatorProps) {
|
||||
const [internalApiKey, setInternalApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [count, setCount] = useState(1);
|
||||
const [configs, setConfigs] = useState<KeyConfig[]>([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
count: 1,
|
||||
balanceLimit: '',
|
||||
balanceLimitReset: '',
|
||||
validityDate: '',
|
||||
},
|
||||
]);
|
||||
const [childKeyToCheck, setChildKeyToCheck] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [keyStatus, setKeyStatus] = useState<{
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
validity_date: number | null;
|
||||
is_expired: boolean;
|
||||
is_drained: boolean;
|
||||
} | null>(null);
|
||||
const [newKeys, setNewKeys] = useState<string[]>([]);
|
||||
const [resultInfo, setResultInfo] = useState<{
|
||||
cost_msats: number;
|
||||
@@ -45,38 +80,72 @@ export function ChildKeyCreator({
|
||||
onApiKeyChange?.(val);
|
||||
};
|
||||
|
||||
const addConfig = () => {
|
||||
setConfigs([
|
||||
...configs,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
count: 1,
|
||||
balanceLimit: '',
|
||||
balanceLimitReset: '',
|
||||
validityDate: '',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeConfig = (id: string) => {
|
||||
if (configs.length > 1) {
|
||||
setConfigs(configs.filter((c) => c.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = (id: string, updates: Partial<KeyConfig>) => {
|
||||
setConfigs(configs.map((c) => (c.id === id ? { ...c, ...updates } : c)));
|
||||
};
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!activeApiKey && baseUrl) {
|
||||
toast.error('Please provide a Parent API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedCount = Math.max(1, Math.min(50, Number(count)));
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await WalletService.createChildKey(
|
||||
baseUrl,
|
||||
activeApiKey,
|
||||
requestedCount
|
||||
);
|
||||
let allNewKeys: string[] = [];
|
||||
let totalCost = 0;
|
||||
let lastParentBalance = 0;
|
||||
|
||||
console.log('Created child keys:', result);
|
||||
for (const config of configs) {
|
||||
const requestedCount = Math.max(1, Math.min(50, Number(config.count)));
|
||||
const result = await WalletService.createChildKey(
|
||||
baseUrl,
|
||||
activeApiKey,
|
||||
requestedCount,
|
||||
config.balanceLimit ? parseInt(config.balanceLimit) : undefined,
|
||||
config.balanceLimitReset || undefined,
|
||||
config.validityDate
|
||||
? Math.floor(
|
||||
new Date(config.validityDate + 'T23:59:59').getTime() / 1000
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
|
||||
if (result.api_keys && result.api_keys.length > 0) {
|
||||
setNewKeys(result.api_keys);
|
||||
} else {
|
||||
throw new Error('No API keys returned from server');
|
||||
if (result.api_keys) {
|
||||
allNewKeys = [...allNewKeys, ...result.api_keys];
|
||||
}
|
||||
totalCost += result.cost_msats;
|
||||
lastParentBalance = result.parent_balance;
|
||||
}
|
||||
|
||||
setNewKeys(allNewKeys);
|
||||
setResultInfo({
|
||||
cost_msats: result.cost_msats,
|
||||
parent_balance: result.parent_balance,
|
||||
cost_msats: totalCost,
|
||||
parent_balance: lastParentBalance,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
`${requestedCount} child API key${
|
||||
requestedCount > 1 ? 's' : ''
|
||||
`${allNewKeys.length} child API key${
|
||||
allNewKeys.length > 1 ? 's' : ''
|
||||
} created successfully`
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -89,6 +158,47 @@ export function ChildKeyCreator({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckKey = async () => {
|
||||
if (!childKeyToCheck) {
|
||||
toast.error('Please provide a Child API key to check');
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
setKeyStatus(null);
|
||||
try {
|
||||
const baseUrlToUse = baseUrl || '';
|
||||
const response = await fetch(`${baseUrlToUse}/v1/balance/info`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${childKeyToCheck}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch key info');
|
||||
}
|
||||
|
||||
const info = await response.json();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
setKeyStatus({
|
||||
total_spent: info.total_spent,
|
||||
balance_limit: info.balance_limit,
|
||||
validity_date: info.validity_date,
|
||||
is_expired: info.validity_date ? now > info.validity_date : false,
|
||||
is_drained: info.balance_limit
|
||||
? info.total_spent >= info.balance_limit
|
||||
: false,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to check child key'
|
||||
);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
setCopiedKey(key);
|
||||
@@ -140,51 +250,116 @@ export function ChildKeyCreator({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='flex-1 space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||
Number of keys
|
||||
</label>
|
||||
<div className='flex flex-col gap-6'>
|
||||
{configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className='bg-muted/30 relative space-y-4 rounded-lg border p-4 pt-6'
|
||||
>
|
||||
{configs.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-destructive hover:bg-destructive/10 hover:text-destructive absolute top-2 right-2 h-7 w-7'
|
||||
onClick={() => removeConfig(config.id)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-end'>
|
||||
<div className='w-full space-y-2 sm:w-32'>
|
||||
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||
Number of keys
|
||||
</label>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.count}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
updateConfig(config.id, {
|
||||
count: isNaN(val)
|
||||
? 1
|
||||
: Math.max(1, Math.min(50, val)),
|
||||
});
|
||||
}}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex-1'>
|
||||
<KeyOptions
|
||||
balanceLimit={config.balanceLimit}
|
||||
setBalanceLimit={(val) =>
|
||||
updateConfig(config.id, { balanceLimit: val })
|
||||
}
|
||||
validityDate={config.validityDate}
|
||||
setValidityDate={(val) =>
|
||||
updateConfig(config.id, { validityDate: val })
|
||||
}
|
||||
balanceLimitReset={config.balanceLimitReset}
|
||||
setBalanceLimitReset={(val) =>
|
||||
updateConfig(config.id, { balanceLimitReset: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={addConfig}
|
||||
className='gap-2 border-dashed'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add Another Configuration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-between gap-4'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{costPerKeyMsats && (
|
||||
<span className='text-muted-foreground text-[10px]'>
|
||||
Cost: {costPerKeyMsats * count} mSats
|
||||
</span>
|
||||
<p>
|
||||
Total Cost:{' '}
|
||||
<span className='text-foreground font-medium'>
|
||||
{costPerKeyMsats *
|
||||
configs.reduce(
|
||||
(acc, c) => acc + Number(c.count),
|
||||
0
|
||||
)}{' '}
|
||||
mSats
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={50}
|
||||
value={count}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val)) {
|
||||
setCount(Math.max(1, Math.min(50, val)));
|
||||
} else {
|
||||
setCount(1);
|
||||
}
|
||||
}}
|
||||
className='w-full sm:w-24'
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateKey}
|
||||
disabled={loading || (!!baseUrl && !activeApiKey)}
|
||||
className='w-full min-w-[140px] sm:w-auto'
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className='mr-2 h-4 w-4' />
|
||||
Generate{' '}
|
||||
{configs.reduce(
|
||||
(acc, c) => acc + Number(c.count),
|
||||
0
|
||||
)}{' '}
|
||||
Keys
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateKey}
|
||||
disabled={loading || (!!baseUrl && !activeApiKey)}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className='mr-2 h-4 w-4' />
|
||||
Generate {count > 1 ? `${count} Keys` : 'Key'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
@@ -281,6 +456,91 @@ export function ChildKeyCreator({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>Check Child Key Status</CardTitle>
|
||||
<CardDescription>
|
||||
View the current spending, limit, and expiration status of any child
|
||||
key.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||
Child API Key
|
||||
</label>
|
||||
<Input
|
||||
value={childKeyToCheck}
|
||||
onChange={(e) => setChildKeyToCheck(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCheckKey}
|
||||
disabled={checking || !childKeyToCheck}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{checking ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Check Status
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{keyStatus && (
|
||||
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Total Spent:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{keyStatus.total_spent} mSats
|
||||
</span>
|
||||
</div>
|
||||
{keyStatus.balance_limit !== null && (
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Limit:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{keyStatus.balance_limit} mSats
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{keyStatus.validity_date !== null && (
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Expires:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{new Date(
|
||||
keyStatus.validity_date * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex gap-2 pt-2'>
|
||||
{keyStatus.is_drained && (
|
||||
<Badge variant='destructive'>Drained</Badge>
|
||||
)}
|
||||
{keyStatus.is_expired && (
|
||||
<Badge variant='destructive'>Expired</Badge>
|
||||
)}
|
||||
{!keyStatus.is_drained && !keyStatus.is_expired && (
|
||||
<Badge className='bg-green-600 hover:bg-green-700'>
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
72
ui/components/key-options.tsx
Normal file
72
ui/components/key-options.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Zap, Calendar, Shield } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface KeyOptionsProps {
|
||||
balanceLimit: string;
|
||||
setBalanceLimit: (val: string) => void;
|
||||
validityDate: string;
|
||||
setValidityDate: (val: string) => void;
|
||||
balanceLimitReset: string;
|
||||
setBalanceLimitReset: (val: string) => void;
|
||||
showBalanceLimit?: boolean;
|
||||
}
|
||||
|
||||
export function KeyOptions({
|
||||
balanceLimit,
|
||||
setBalanceLimit,
|
||||
validityDate,
|
||||
setValidityDate,
|
||||
balanceLimitReset,
|
||||
setBalanceLimitReset,
|
||||
showBalanceLimit = true,
|
||||
}: KeyOptionsProps) {
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-3'>
|
||||
{showBalanceLimit && (
|
||||
<div className='space-y-2'>
|
||||
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
|
||||
<Zap className='h-3 w-3' />
|
||||
Balance Limit (mSats)
|
||||
</label>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='No limit'
|
||||
value={balanceLimit}
|
||||
onChange={(e) => setBalanceLimit(e.target.value)}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
|
||||
<Calendar className='h-3 w-3' />
|
||||
Validity Date
|
||||
</label>
|
||||
<Input
|
||||
type='date'
|
||||
value={validityDate}
|
||||
onChange={(e) => setValidityDate(e.target.value)}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
|
||||
<Shield className='h-3 w-3' />
|
||||
Reset Policy
|
||||
</label>
|
||||
<select
|
||||
value={balanceLimitReset}
|
||||
onChange={(e) => setBalanceLimitReset(e.target.value)}
|
||||
className='bg-background border-input flex h-9 w-full rounded-md border px-3 py-1 text-xs shadow-sm transition-colors'
|
||||
>
|
||||
<option value=''>None</option>
|
||||
<option value='daily'>Daily</option>
|
||||
<option value='weekly'>Weekly</option>
|
||||
<option value='monthly'>Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,19 +7,8 @@ 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;
|
||||
};
|
||||
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
|
||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||
|
||||
interface ApiKeyManagerProps {
|
||||
baseUrl: string;
|
||||
@@ -51,12 +40,28 @@ async function fetchWalletInfo(
|
||||
api_key: string;
|
||||
balance: number;
|
||||
reserved?: number;
|
||||
is_child: boolean;
|
||||
parent_key: string | null;
|
||||
total_requests: number;
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
balance_limit_reset: string | null;
|
||||
validity_date: number | null;
|
||||
child_keys?: ChildKeyInfo[];
|
||||
};
|
||||
|
||||
return {
|
||||
apiKey: payload.api_key || apiKey,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
isChild: payload.is_child,
|
||||
parentKey: payload.parent_key,
|
||||
totalRequests: payload.total_requests,
|
||||
totalSpent: payload.total_spent,
|
||||
balanceLimit: payload.balance_limit,
|
||||
balanceLimitReset: payload.balance_limit_reset,
|
||||
validityDate: payload.validity_date,
|
||||
childKeys: payload.child_keys,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { KeyOptions } from '@/components/key-options';
|
||||
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
|
||||
|
||||
type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
};
|
||||
|
||||
type RefundReceipt = {
|
||||
export type RefundReceipt = {
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
@@ -53,12 +49,28 @@ async function fetchWalletInfo(
|
||||
api_key: string;
|
||||
balance: number;
|
||||
reserved?: number;
|
||||
is_child: boolean;
|
||||
parent_key: string | null;
|
||||
total_requests: number;
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
balance_limit_reset: string | null;
|
||||
validity_date: number | null;
|
||||
child_keys?: ChildKeyInfo[];
|
||||
};
|
||||
|
||||
return {
|
||||
apiKey: payload.api_key || apiKey,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
isChild: payload.is_child,
|
||||
parentKey: payload.parent_key,
|
||||
totalRequests: payload.total_requests,
|
||||
totalSpent: payload.total_spent,
|
||||
balanceLimit: payload.balance_limit,
|
||||
balanceLimitReset: payload.balance_limit_reset,
|
||||
validityDate: payload.validity_date,
|
||||
childKeys: payload.child_keys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,9 +98,11 @@ export function CashuPaymentWorkflow({
|
||||
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 [balanceLimit, setBalanceLimit] = useState<string>('');
|
||||
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
|
||||
const [validityDate, setValidityDate] = useState<string>('');
|
||||
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
|
||||
@@ -121,6 +135,15 @@ export function CashuPaymentWorkflow({
|
||||
const params = new URLSearchParams({
|
||||
initial_balance_token: initialToken.trim(),
|
||||
});
|
||||
if (balanceLimit) params.append('balance_limit', balanceLimit);
|
||||
if (balanceLimitReset)
|
||||
params.append('balance_limit_reset', balanceLimitReset);
|
||||
if (validityDate) {
|
||||
const timestamp = Math.floor(
|
||||
new Date(validityDate + 'T23:59:59').getTime() / 1000
|
||||
);
|
||||
params.append('validity_date', timestamp.toString());
|
||||
}
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v1/balance/create?${params.toString()}`,
|
||||
{
|
||||
@@ -135,11 +158,25 @@ export function CashuPaymentWorkflow({
|
||||
const payload = (await response.json()) as {
|
||||
api_key: string;
|
||||
balance: number;
|
||||
is_child: boolean;
|
||||
parent_key: string | null;
|
||||
total_requests: number;
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
balance_limit_reset: string | null;
|
||||
validity_date: number | null;
|
||||
};
|
||||
const snapshot: WalletSnapshot = {
|
||||
apiKey: payload.api_key,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: 0,
|
||||
isChild: payload.is_child ?? false,
|
||||
parentKey: payload.parent_key ?? null,
|
||||
totalRequests: payload.total_requests ?? 0,
|
||||
totalSpent: payload.total_spent ?? 0,
|
||||
balanceLimit: payload.balance_limit ?? null,
|
||||
balanceLimitReset: payload.balance_limit_reset ?? null,
|
||||
validityDate: payload.validity_date ?? null,
|
||||
};
|
||||
|
||||
setApiKeyInput(snapshot.apiKey);
|
||||
@@ -154,7 +191,14 @@ export function CashuPaymentWorkflow({
|
||||
} finally {
|
||||
setIsCreatingKey(false);
|
||||
}
|
||||
}, [initialToken, baseUrl, onApiKeyCreated]);
|
||||
}, [
|
||||
initialToken,
|
||||
baseUrl,
|
||||
onApiKeyCreated,
|
||||
balanceLimit,
|
||||
balanceLimitReset,
|
||||
validityDate,
|
||||
]);
|
||||
|
||||
const handleSyncBalance = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
@@ -256,11 +300,10 @@ export function CashuPaymentWorkflow({
|
||||
[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);
|
||||
const showCreateDetails = initialToken.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -285,12 +328,21 @@ export function CashuPaymentWorkflow({
|
||||
value={initialToken}
|
||||
onChange={(event) => setInitialToken(event.target.value)}
|
||||
placeholder='cashuA1...'
|
||||
rows={showCreateDetails ? 4 : 2}
|
||||
rows={4}
|
||||
className='font-mono text-sm transition-all duration-200'
|
||||
onFocus={() => setHasInteractedCreate(true)}
|
||||
/>
|
||||
{showCreateDetails && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<div className='space-y-4'>
|
||||
<KeyOptions
|
||||
balanceLimit={balanceLimit}
|
||||
setBalanceLimit={setBalanceLimit}
|
||||
validityDate={validityDate}
|
||||
setValidityDate={setValidityDate}
|
||||
balanceLimitReset={balanceLimitReset}
|
||||
setBalanceLimitReset={setBalanceLimitReset}
|
||||
showBalanceLimit={false}
|
||||
/>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<Button
|
||||
onClick={handleCreateKey}
|
||||
disabled={isCreatingKey}
|
||||
@@ -298,11 +350,13 @@ export function CashuPaymentWorkflow({
|
||||
>
|
||||
{isCreatingKey ? 'Creating…' : 'Create API key'}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
<span className='text-muted-foreground text-[0.7rem] leading-relaxed'>
|
||||
Redeems instantly and returns <code>sk-</code> key.
|
||||
<br />
|
||||
Optional limits can be set above for enhanced security.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -11,29 +11,24 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
|
||||
import {
|
||||
CashuPaymentWorkflow,
|
||||
type RefundReceipt,
|
||||
} from './cashu-payment-workflow';
|
||||
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
||||
import { ApiKeyManager } from './api-key-manager';
|
||||
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
|
||||
import { ChildKeyCreator } from '@/components/child-key-creator';
|
||||
|
||||
type NodeInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
npub?: string | null;
|
||||
mints: string[];
|
||||
http_url?: string | null;
|
||||
onion_url?: string | null;
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
http_url?: string;
|
||||
onion_url?: string;
|
||||
npub?: string;
|
||||
mints?: string[];
|
||||
child_key_cost_msats?: number;
|
||||
};
|
||||
|
||||
type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
};
|
||||
|
||||
type RefundReceipt = {
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
@@ -285,7 +280,7 @@ export function CheatSheet(): JSX.Element {
|
||||
Cashu mints
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{nodeInfo.mints.length ? (
|
||||
{nodeInfo.mints?.length ? (
|
||||
nodeInfo.mints.map((mint) => (
|
||||
<Badge
|
||||
key={mint}
|
||||
@@ -364,10 +359,11 @@ export function CheatSheet(): JSX.Element {
|
||||
</section>
|
||||
|
||||
<Tabs defaultValue='cashu' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
|
||||
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
|
||||
<TabsList className='grid w-full grid-cols-5'>
|
||||
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
|
||||
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
|
||||
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
||||
<TabsTrigger value='details'>Key Details</TabsTrigger>
|
||||
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -426,6 +422,16 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='details' className='space-y-4'>
|
||||
<KeyInfoDetails
|
||||
baseUrl={normalizedBaseUrl}
|
||||
apiKey={apiKeyInput}
|
||||
walletInfo={walletInfo}
|
||||
onApiKeyChanged={handleApiKeyChanged}
|
||||
onWalletInfoUpdated={handleWalletInfoUpdated}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='child-keys' className='space-y-4'>
|
||||
<ChildKeyCreator
|
||||
baseUrl={normalizedBaseUrl}
|
||||
|
||||
429
ui/components/landing/key-info-details.tsx
Normal file
429
ui/components/landing/key-info-details.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
History,
|
||||
Users,
|
||||
RotateCcw,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { WalletService } from '@/lib/api/services/wallet';
|
||||
|
||||
export type ChildKeyInfo = {
|
||||
api_key: string;
|
||||
total_requests: number;
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
balance_limit_reset: string | null;
|
||||
validity_date: number | null;
|
||||
};
|
||||
|
||||
export type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
isChild: boolean;
|
||||
parentKey: string | null;
|
||||
totalRequests: number;
|
||||
totalSpent: number;
|
||||
balanceLimit: number | null;
|
||||
balanceLimitReset: string | null;
|
||||
validityDate: number | null;
|
||||
childKeys?: ChildKeyInfo[];
|
||||
};
|
||||
|
||||
interface KeyInfoDetailsProps {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
walletInfo?: WalletSnapshot | null;
|
||||
onApiKeyChanged?: (apiKey: string) => void;
|
||||
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
|
||||
}
|
||||
|
||||
export function KeyInfoDetails({
|
||||
baseUrl,
|
||||
apiKey = '',
|
||||
walletInfo = null,
|
||||
onApiKeyChanged,
|
||||
onWalletInfoUpdated,
|
||||
}: KeyInfoDetailsProps): JSX.Element {
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState<string | null>(null);
|
||||
|
||||
// Sync internal state with props if they change
|
||||
useEffect(() => {
|
||||
setApiKeyInput(apiKey);
|
||||
}, [apiKey]);
|
||||
|
||||
const fetchDetails = useCallback(
|
||||
async (keyToFetch: string) => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||
headers: { Authorization: `Bearer ${keyToFetch}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch key info');
|
||||
}
|
||||
const payload = await response.json();
|
||||
const snapshot: WalletSnapshot = {
|
||||
apiKey: payload.api_key || keyToFetch,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
isChild: payload.is_child,
|
||||
parentKey: payload.parent_key,
|
||||
totalRequests: payload.total_requests,
|
||||
totalSpent: payload.total_spent,
|
||||
balanceLimit: payload.balance_limit,
|
||||
balanceLimitReset: payload.balance_limit_reset,
|
||||
validityDate: payload.validity_date,
|
||||
childKeys: payload.child_keys,
|
||||
};
|
||||
onWalletInfoUpdated?.(snapshot);
|
||||
toast.success('Key details synced');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to fetch details'
|
||||
);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
[baseUrl, onWalletInfoUpdated]
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!apiKeyInput) return;
|
||||
await fetchDetails(apiKeyInput);
|
||||
};
|
||||
|
||||
const handleKeyChange = (newKey: string) => {
|
||||
setApiKeyInput(newKey);
|
||||
onApiKeyChanged?.(newKey);
|
||||
// Optionally clear info when key changes
|
||||
if (newKey !== apiKey) {
|
||||
onWalletInfoUpdated?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const handleResetSpent = async (childKey: string) => {
|
||||
if (!walletInfo || walletInfo.isChild) return;
|
||||
|
||||
setIsResetting(childKey);
|
||||
try {
|
||||
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
|
||||
toast.success('Child key spent reset');
|
||||
await fetchDetails(apiKeyInput);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to reset child key'
|
||||
);
|
||||
} finally {
|
||||
setIsResetting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSats = (msats: number) =>
|
||||
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
const formatMsats = (msats: number) =>
|
||||
new Intl.NumberFormat('en-US').format(msats);
|
||||
const formatDate = (timestamp: number | null) =>
|
||||
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<KeyRound className='text-primary h-5 w-5' />
|
||||
Key Information
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter an API key to view its balance, consumption, and child keys.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-10 w-10 shrink-0'
|
||||
onClick={() => handleCopy(apiKeyInput)}
|
||||
disabled={!apiKeyInput}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='min-w-[80px] gap-1'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || !apiKeyInput}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isRefreshing ? 'Syncing...' : 'Sync'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{walletInfo && (
|
||||
<>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<ShieldCheck className='text-primary h-5 w-5' />
|
||||
Status & Identity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>Type</span>
|
||||
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
|
||||
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
|
||||
</Badge>
|
||||
</div>
|
||||
{walletInfo.parentKey && (
|
||||
<div className='space-y-1'>
|
||||
<span className='text-muted-foreground text-xs tracking-wider uppercase'>
|
||||
Parent Key
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
|
||||
{walletInfo.parentKey}
|
||||
</code>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() => handleCopy(walletInfo.parentKey!)}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Validity
|
||||
</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{formatDate(walletInfo.validityDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spendable Balance
|
||||
</span>
|
||||
<span className='text-primary font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.balanceMsats)} sats
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<History className='text-primary h-5 w-5' />
|
||||
Consumption
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Requests
|
||||
</span>
|
||||
<span className='font-mono text-sm font-medium'>
|
||||
{walletInfo.totalRequests}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Spent
|
||||
</span>
|
||||
<div className='text-right'>
|
||||
<p className='font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.totalSpent)} sats
|
||||
</p>
|
||||
<p className='text-muted-foreground font-mono text-[0.6rem]'>
|
||||
{formatMsats(walletInfo.totalSpent)} msats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{walletInfo.balanceLimit !== null && (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spend Limit
|
||||
</span>
|
||||
<span className='font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.balanceLimit)} sats
|
||||
</span>
|
||||
</div>
|
||||
{walletInfo.balanceLimitReset && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Reset Policy
|
||||
</span>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{walletInfo.balanceLimitReset}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{!walletInfo.isChild &&
|
||||
walletInfo.childKeys &&
|
||||
walletInfo.childKeys.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<Users className='text-primary h-5 w-5' />
|
||||
Child Keys ({walletInfo.childKeys.length})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Secondary keys using this account's balance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{walletInfo.childKeys.map((ck) => (
|
||||
<div
|
||||
key={ck.api_key}
|
||||
className='space-y-3 rounded-lg border p-4'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
|
||||
{ck.api_key}
|
||||
</code>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() => handleCopy(ck.api_key)}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-destructive h-8 w-8'
|
||||
title='Reset consumption'
|
||||
disabled={isResetting === ck.api_key}
|
||||
onClick={() => handleResetSpent(ck.api_key)}
|
||||
>
|
||||
{isResetting === ck.api_key ? (
|
||||
<RefreshCcw className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RotateCcw className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4 text-xs sm:grid-cols-5'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
Requests
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
{ck.total_requests}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
Spent
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
{formatSats(ck.total_spent)} sats
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
Limit
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
{ck.balance_limit
|
||||
? `${formatSats(ck.balance_limit)} sats`
|
||||
: 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
Policy
|
||||
</p>
|
||||
<p className='font-medium capitalize'>
|
||||
{ck.balance_limit_reset || 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
Expires
|
||||
</p>
|
||||
<p className='font-medium'>
|
||||
{formatDate(ck.validity_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className='text-muted-foreground'
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Last synced: {new Date().toLocaleTimeString()}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,12 +10,8 @@ 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;
|
||||
};
|
||||
import { KeyOptions } from '@/components/key-options';
|
||||
import type { WalletSnapshot } from './key-info-details';
|
||||
|
||||
type LightningInvoice = {
|
||||
invoice_id: string;
|
||||
@@ -89,7 +85,10 @@ export function LightningPaymentWorkflow({
|
||||
const [isTopupping, setIsTopupping] = useState(false);
|
||||
const [isRecovering, setIsRecovering] = useState(false);
|
||||
|
||||
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
|
||||
const [balanceLimit, setBalanceLimit] = useState<string>('');
|
||||
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
|
||||
const [validityDate, setValidityDate] = useState<string>('');
|
||||
|
||||
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
||||
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
|
||||
|
||||
@@ -166,13 +165,29 @@ export function LightningPaymentWorkflow({
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
const payload: {
|
||||
amount_sats: number;
|
||||
purpose: string;
|
||||
balance_limit?: number;
|
||||
balance_limit_reset?: string;
|
||||
validity_date?: number;
|
||||
} = {
|
||||
amount_sats: amount,
|
||||
purpose: 'create',
|
||||
};
|
||||
|
||||
if (balanceLimit) payload.balance_limit = parseInt(balanceLimit);
|
||||
if (balanceLimitReset) payload.balance_limit_reset = balanceLimitReset;
|
||||
if (validityDate) {
|
||||
payload.validity_date = Math.floor(
|
||||
new Date(validityDate + 'T23:59:59').getTime() / 1000
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount_sats: amount,
|
||||
purpose: 'create',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -195,6 +210,13 @@ export function LightningPaymentWorkflow({
|
||||
apiKey: status.api_key,
|
||||
balanceMsats: status.amount_sats * 1000,
|
||||
reservedMsats: 0,
|
||||
isChild: false,
|
||||
parentKey: null,
|
||||
totalRequests: 0,
|
||||
totalSpent: 0,
|
||||
balanceLimit: null,
|
||||
balanceLimitReset: null,
|
||||
validityDate: null,
|
||||
};
|
||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||
setCreatedApiKey(status.api_key);
|
||||
@@ -214,7 +236,15 @@ export function LightningPaymentWorkflow({
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated]);
|
||||
}, [
|
||||
createAmount,
|
||||
baseUrl,
|
||||
pollInvoiceStatus,
|
||||
onApiKeyCreated,
|
||||
balanceLimit,
|
||||
balanceLimitReset,
|
||||
validityDate,
|
||||
]);
|
||||
|
||||
const handleTopupInvoice = useCallback(async (): Promise<void> => {
|
||||
const amount = parseInt(topupAmount);
|
||||
@@ -261,6 +291,13 @@ export function LightningPaymentWorkflow({
|
||||
apiKey: status.api_key,
|
||||
balanceMsats: status.amount_sats * 1000,
|
||||
reservedMsats: 0,
|
||||
isChild: false,
|
||||
parentKey: null,
|
||||
totalRequests: 0,
|
||||
totalSpent: 0,
|
||||
balanceLimit: null,
|
||||
balanceLimitReset: null,
|
||||
validityDate: null,
|
||||
};
|
||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||
setTopupApiKeyResult(status.api_key);
|
||||
@@ -315,6 +352,13 @@ export function LightningPaymentWorkflow({
|
||||
apiKey: status.api_key,
|
||||
balanceMsats: status.amount_sats * 1000,
|
||||
reservedMsats: 0,
|
||||
isChild: false,
|
||||
parentKey: null,
|
||||
totalRequests: 0,
|
||||
totalSpent: 0,
|
||||
balanceLimit: null,
|
||||
balanceLimitReset: null,
|
||||
validityDate: null,
|
||||
};
|
||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||
setRecoveredApiKey(status.api_key);
|
||||
@@ -333,14 +377,13 @@ export function LightningPaymentWorkflow({
|
||||
}
|
||||
}, [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;
|
||||
const showCreateDetails = createAmount.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -367,9 +410,18 @@ export function LightningPaymentWorkflow({
|
||||
onChange={(event) => setCreateAmount(event.target.value)}
|
||||
placeholder='Amount in sats (e.g., 1000)'
|
||||
className='text-sm'
|
||||
onFocus={() => setHasInteractedCreate(true)}
|
||||
/>
|
||||
{showCreateDetails && (
|
||||
<div className='space-y-4'>
|
||||
<KeyOptions
|
||||
balanceLimit={balanceLimit}
|
||||
setBalanceLimit={setBalanceLimit}
|
||||
validityDate={validityDate}
|
||||
setValidityDate={setValidityDate}
|
||||
balanceLimitReset={balanceLimitReset}
|
||||
setBalanceLimitReset={setBalanceLimitReset}
|
||||
showBalanceLimit={false}
|
||||
/>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={handleCreateInvoice}
|
||||
@@ -473,7 +525,7 @@ export function LightningPaymentWorkflow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -341,6 +341,23 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
static async batchOverrideProviderModels(
|
||||
providerId: number,
|
||||
models: AdminModel[]
|
||||
): Promise<{ ok: boolean; count: number; message: string }> {
|
||||
const payload = {
|
||||
models: models.map((m) => ({
|
||||
...m,
|
||||
pricing: this.convertPricingToPerToken(m.pricing),
|
||||
})),
|
||||
};
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
count: number;
|
||||
message: string;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/batch-override`, payload);
|
||||
}
|
||||
|
||||
static async getProviderModel(
|
||||
providerId: number,
|
||||
modelId: string
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { apiClient } from '../client';
|
||||
import { ConfigurationService } from './configuration';
|
||||
import axios from 'axios';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export type LoginRequest = z.infer<typeof loginSchema>;
|
||||
|
||||
export const loginResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type LoginResponse = z.infer<typeof loginResponseSchema>;
|
||||
|
||||
export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
try {
|
||||
return await apiClient.post<LoginResponse>('/api/login', data);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const adminLoginSchema = z.object({
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
@@ -91,40 +68,3 @@ export async function adminLogout(): Promise<void> {
|
||||
ConfigurationService.clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
export const registerSchema = z.object({
|
||||
npub: z.string().min(10, { message: 'must have at least 10 character' }),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RegisterRequest = z.infer<typeof registerSchema>;
|
||||
export type SchemaRegisterProps = z.infer<typeof registerSchema>;
|
||||
|
||||
export const registerResponseSchema = z.object({
|
||||
user_id: z.string(),
|
||||
theme: z.string(),
|
||||
});
|
||||
|
||||
export type RegisterResponse = z.infer<typeof registerResponseSchema>;
|
||||
|
||||
export async function register(
|
||||
data: RegisterRequest
|
||||
): Promise<RegisterResponse> {
|
||||
try {
|
||||
return await apiClient.post<RegisterResponse>('/api/register', data);
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const registerUser = register;
|
||||
|
||||
export async function getUserSettings(): Promise<{ id: string }> {
|
||||
try {
|
||||
return await apiClient.get<{ id: string }>('/api/user/settings');
|
||||
} catch (error) {
|
||||
console.error('Error fetching user settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,10 @@ export class WalletService {
|
||||
static async createChildKey(
|
||||
baseUrl?: string,
|
||||
apiKey?: string,
|
||||
count: number = 1
|
||||
count: number = 1,
|
||||
balanceLimit?: number,
|
||||
balanceLimitReset?: string,
|
||||
validityDate?: number
|
||||
): Promise<CreateChildKeyResponse> {
|
||||
try {
|
||||
if (baseUrl && apiKey) {
|
||||
@@ -144,7 +147,12 @@ export class WalletService {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ count }),
|
||||
body: JSON.stringify({
|
||||
count,
|
||||
balance_limit: balanceLimit,
|
||||
balance_limit_reset: balanceLimitReset,
|
||||
validity_date: validityDate,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -157,11 +165,49 @@ export class WalletService {
|
||||
|
||||
return await apiClient.post<CreateChildKeyResponse>(
|
||||
'/v1/balance/child-key',
|
||||
{ count }
|
||||
{
|
||||
count,
|
||||
balance_limit: balanceLimit,
|
||||
balance_limit_reset: balanceLimitReset,
|
||||
validity_date: validityDate,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating child key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async resetChildKeySpent(
|
||||
baseUrl: string | undefined,
|
||||
parentKey: string,
|
||||
childKey: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const url = baseUrl
|
||||
? `${baseUrl}/v1/balance/child-key/reset`
|
||||
: '/v1/balance/child-key/reset';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${parentKey}`,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ child_key: childKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Failed to reset child key');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error resetting child key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user