mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
138 Commits
refactor/r
...
add-missin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d48b36be8 | ||
|
|
8b3fdaa545 | ||
|
|
b43d57df23 | ||
|
|
b9a5a9276e | ||
|
|
a79fdf7212 | ||
|
|
9560050946 | ||
|
|
dd88e9b172 | ||
|
|
e31b45fa9e | ||
|
|
1ed8b29d64 | ||
|
|
59f8d31719 | ||
|
|
93a368b1a2 | ||
|
|
d3dd346853 | ||
|
|
0198569a9a | ||
|
|
7e648cb5c2 | ||
|
|
deb75624f3 | ||
|
|
1fa71ee806 | ||
|
|
97d164e181 | ||
|
|
9f3c915dec | ||
|
|
4ff6218140 | ||
|
|
67ea6aec43 | ||
|
|
41f346417a | ||
|
|
4857d741de | ||
|
|
eabe17cc26 | ||
|
|
8f298ae9c1 | ||
|
|
e5f3b7d755 | ||
|
|
3796e29cb4 | ||
|
|
812f50ea28 | ||
|
|
ce5fa136c2 | ||
|
|
4643aefb22 | ||
|
|
5aa902fd4a | ||
|
|
db144e0903 | ||
|
|
e20b20dbca | ||
|
|
aa700443d3 | ||
|
|
7e89b5bc6d | ||
|
|
8dbf036558 | ||
|
|
12f2cfecc9 | ||
|
|
a0f3378cf5 | ||
|
|
20b4c3a641 | ||
|
|
88f7ee734f | ||
|
|
326a0086b7 | ||
|
|
eb46651373 | ||
|
|
226188e22d | ||
|
|
89fc48eea2 | ||
|
|
f66d98e6b5 | ||
|
|
cfea4f9cd1 | ||
|
|
c74a877ab5 | ||
|
|
83a76e86fe | ||
|
|
66975ee271 | ||
|
|
5788d63892 | ||
|
|
d0c7cc6bd9 | ||
|
|
40096867d9 | ||
|
|
e002c0b66f | ||
|
|
608549d051 | ||
|
|
72f389c8d2 | ||
|
|
97daddb95a | ||
|
|
847f4b07a5 | ||
|
|
4bce04ada4 | ||
|
|
f1e2448620 | ||
|
|
28340a152c | ||
|
|
e28f6118e7 | ||
|
|
9fb6f54d12 | ||
|
|
3cb8d7b5dd | ||
|
|
ede076b881 | ||
|
|
449a0951f9 | ||
|
|
8c9ede2272 | ||
|
|
e794b09614 | ||
|
|
24f6519267 | ||
|
|
f57beb6411 | ||
|
|
3e41e59a1d | ||
|
|
002d750830 | ||
|
|
8c2eb55760 | ||
|
|
350714f23a | ||
|
|
9cc2e84f7c | ||
|
|
6fbd479bdc | ||
|
|
f67c26935a | ||
|
|
9ce91f58a9 | ||
|
|
bb82361434 | ||
|
|
5f1d67e87e | ||
|
|
b2106ad1e6 | ||
|
|
709a4ba0dc | ||
|
|
21f421b212 | ||
|
|
b3c5e4cbf6 | ||
|
|
1d95379328 | ||
|
|
342a7f7f16 | ||
|
|
7694f20006 | ||
|
|
3ea0a267dd | ||
|
|
68e537f6fb | ||
|
|
814c39898d | ||
|
|
fdd4f12f5c | ||
|
|
6957d8c0d9 | ||
|
|
2c358276bf | ||
|
|
48529f672a | ||
|
|
030c2f65e4 | ||
|
|
3510402af2 | ||
|
|
5f376d716d | ||
|
|
d889274f84 | ||
|
|
0c0f19d854 | ||
|
|
b61bffc666 | ||
|
|
af658136d4 | ||
|
|
8973627b5f | ||
|
|
25f427033a | ||
|
|
d3dd8318e4 | ||
|
|
c5fd386c1e | ||
|
|
52c7f17215 | ||
|
|
2c03302055 | ||
|
|
c3221f2a31 | ||
|
|
ce9834d7ec | ||
|
|
6ebe73f2f7 | ||
|
|
98aecb08f9 | ||
|
|
58fa063c6b | ||
|
|
7c94f60797 | ||
|
|
4cb4c6dfec | ||
|
|
512b686e5f | ||
|
|
f495a10eeb | ||
|
|
d1692edb63 | ||
|
|
8f81bcd2fc | ||
|
|
6a5ed9d063 | ||
|
|
b9890e6ad5 | ||
|
|
e4b8293d41 | ||
|
|
ba5f9fc181 | ||
|
|
795fff61e0 | ||
|
|
f9bfd4f0d2 | ||
|
|
5683382ada | ||
|
|
c92372dafd | ||
|
|
b58dd78fde | ||
|
|
4c31bf9767 | ||
|
|
42efa3c1ba | ||
|
|
74b1d39d5c | ||
|
|
4df4976f44 | ||
|
|
1af39f043f | ||
|
|
1751cd3b47 | ||
|
|
c75f170ed0 | ||
|
|
248937e05f | ||
|
|
31898192b2 | ||
|
|
e50facc835 | ||
|
|
55dc485705 | ||
|
|
80559a57d5 | ||
|
|
8e9f6647e7 |
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add provider_settings to upstream_providers
|
||||
|
||||
Revision ID: 614c0a740e68
|
||||
Revises: 06f81c0fc88d
|
||||
Create Date: 2026-02-13 22:36:53.608737
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "614c0a740e68"
|
||||
down_revision = "06f81c0fc88d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if column exists before adding it
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [c["name"] for c in inspector.get_columns("upstream_providers")]
|
||||
|
||||
if "provider_settings" not in columns:
|
||||
op.add_column(
|
||||
"upstream_providers",
|
||||
sa.Column("provider_settings", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("upstream_providers", "provider_settings")
|
||||
@@ -118,6 +118,13 @@ def create_model_mappings(
|
||||
|
||||
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
|
||||
unique_models: dict[str, "Model"] = {}
|
||||
seen_model_provider: set[tuple[str, str]] = set()
|
||||
|
||||
providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {}
|
||||
for upstream in upstreams:
|
||||
db_id = getattr(upstream, "db_id", None)
|
||||
if isinstance(db_id, int):
|
||||
providers_by_db_id[db_id] = upstream
|
||||
|
||||
# Separate OpenRouter from other providers
|
||||
openrouter: "BaseUpstreamProvider" | None = None
|
||||
@@ -134,6 +141,16 @@ def create_model_mappings(
|
||||
"""Get base model ID by removing provider prefix."""
|
||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||
|
||||
def get_provider_identity(upstream: "BaseUpstreamProvider") -> str:
|
||||
"""Get a stable provider identity used for deduplication."""
|
||||
db_id = getattr(upstream, "db_id", None)
|
||||
if isinstance(db_id, int):
|
||||
return f"db:{db_id}"
|
||||
|
||||
provider_type = str(getattr(upstream, "provider_type", "") or "").lower()
|
||||
base_url = str(getattr(upstream, "base_url", "") or "").lower()
|
||||
return f"{provider_type}|{base_url}"
|
||||
|
||||
def _add_candidate(
|
||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||
) -> None:
|
||||
@@ -148,6 +165,7 @@ def create_model_mappings(
|
||||
) -> None:
|
||||
"""Process all models from a given provider."""
|
||||
upstream_prefix = getattr(upstream, "upstream_name", None)
|
||||
provider_key = get_provider_identity(upstream)
|
||||
|
||||
for model in upstream.get_cached_models():
|
||||
if not model.enabled or model.id in disabled_model_ids:
|
||||
@@ -189,6 +207,7 @@ def create_model_mappings(
|
||||
# Try to set each alias
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream)
|
||||
seen_model_provider.add((model_to_use.id.lower(), provider_key))
|
||||
|
||||
# Process non-OpenRouter providers first
|
||||
for upstream in other_upstreams:
|
||||
@@ -198,6 +217,85 @@ def create_model_mappings(
|
||||
if openrouter:
|
||||
process_provider_models(openrouter, is_openrouter=True)
|
||||
|
||||
# Include enabled DB overrides even when provider discovery misses models.
|
||||
# This is important for deployment-based providers like Azure.
|
||||
for model_id, override_data in overrides_by_id.items():
|
||||
if model_id in disabled_model_ids:
|
||||
continue
|
||||
override_row, provider_fee = override_data
|
||||
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
|
||||
if not isinstance(upstream_provider_id, int):
|
||||
continue
|
||||
|
||||
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
|
||||
if upstream_for_override is None:
|
||||
continue
|
||||
|
||||
provider_key = get_provider_identity(upstream_for_override)
|
||||
dedupe_key = (model_id.lower(), provider_key)
|
||||
if dedupe_key in seen_model_provider:
|
||||
continue
|
||||
|
||||
try:
|
||||
model_to_use = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Skipping invalid model override while building model mappings",
|
||||
extra={
|
||||
"model_id": model_id,
|
||||
"upstream_provider_id": upstream_provider_id,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
if not model_to_use.enabled:
|
||||
continue
|
||||
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
is_openrouter = (
|
||||
getattr(upstream_for_override, "base_url", "")
|
||||
== "https://openrouter.ai/api/v1"
|
||||
)
|
||||
if not is_openrouter or base_id not in unique_models:
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream_for_override.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
|
||||
try:
|
||||
aliases = resolve_model_alias(
|
||||
model_to_use.id,
|
||||
model_to_use.canonical_slug,
|
||||
alias_ids=model_to_use.alias_ids,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Skipping model aliases for invalid override model",
|
||||
extra={
|
||||
"model_id": model_id,
|
||||
"upstream_provider_id": upstream_provider_id,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
upstream_prefix = getattr(upstream_for_override, "upstream_name", None)
|
||||
if upstream_prefix and "/" not in model_to_use.id:
|
||||
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
|
||||
if prefixed_id not in aliases:
|
||||
aliases.append(prefixed_id)
|
||||
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream_for_override)
|
||||
seen_model_provider.add(dedupe_key)
|
||||
|
||||
# Sort candidates and build final maps
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
|
||||
350
routstr/auth.py
350
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(
|
||||
@@ -242,6 +348,8 @@ async def validate_bearer_key(
|
||||
)
|
||||
|
||||
return new_key
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
@@ -311,6 +419,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 +459,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 +532,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]
|
||||
|
||||
@@ -426,12 +590,15 @@ async def pay_for_request(
|
||||
|
||||
async def revert_pay_for_request(
|
||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Revert a previously reserved payment. Returns True if revert succeeded,
|
||||
False if the reservation was already released (prevents negative reserved_balance)."""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
@@ -440,19 +607,23 @@ 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)
|
||||
.where(col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.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]
|
||||
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
logger.warning(
|
||||
"Revert skipped - reservation already released (no-op to prevent negative reserved_balance)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
@@ -460,19 +631,11 @@ async def revert_pay_for_request(
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
return False
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
return True
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
@@ -504,20 +667,45 @@ async def adjust_payment_for_tokens(
|
||||
release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
|
||||
)
|
||||
)
|
||||
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||
result = 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)
|
||||
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||
.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)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
if result.rowcount == 0: # type: ignore[union-attr]
|
||||
logger.warning(
|
||||
"Release reservation skipped - already released (no-op to prevent negative reserved_balance)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Released reservation without charging (fallback)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to release reservation in fallback",
|
||||
@@ -551,12 +739,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 +823,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 +869,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 +937,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]
|
||||
|
||||
@@ -803,15 +1007,67 @@ async def adjust_payment_for_tokens(
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback: should not reach here, but release reservation just in case
|
||||
logger.error(
|
||||
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
await release_reservation_only()
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": deducted_max_cost,
|
||||
}
|
||||
# All calculate_cost variants are handled above.
|
||||
raise AssertionError("Unreachable: unhandled calculate_cost result")
|
||||
|
||||
|
||||
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,12 @@ async def refund_wallet_endpoint(
|
||||
|
||||
bearer_value: str = authorization[7:]
|
||||
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
|
||||
if key.total_balance <= 0:
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -232,7 +269,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 +292,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 +344,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 +368,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"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
@@ -128,6 +148,9 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
provider_fee: float = Field(
|
||||
default=1.01, description="Provider fee multiplier (default 1%)"
|
||||
)
|
||||
provider_settings: str | None = Field(
|
||||
default=None, description="JSON string for provider-specific settings"
|
||||
)
|
||||
models: list["ModelRow"] = Relationship(
|
||||
back_populates="upstream_provider",
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
@@ -163,11 +186,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"
|
||||
|
||||
@@ -19,6 +19,7 @@ class LogManager:
|
||||
specific_date: str | None = None,
|
||||
reverse_files: bool = False,
|
||||
max_files: int | None = None,
|
||||
window_center: datetime | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""
|
||||
Yields log entries from files.
|
||||
@@ -28,12 +29,14 @@ class LogManager:
|
||||
specific_date: specific date string (YYYY-MM-DD) to look at.
|
||||
reverse_files: if True, process files in reverse order (newest first).
|
||||
max_files: maximum number of log files to process (most recent if reverse_files is True).
|
||||
window_center: datetime object to center a 5-month window around.
|
||||
"""
|
||||
if not self.logs_dir.exists():
|
||||
return
|
||||
|
||||
log_files = []
|
||||
cutoff_date = None
|
||||
cutoff_timestamp_str: str | None = None
|
||||
|
||||
if specific_date:
|
||||
log_file = self.logs_dir / f"app_{specific_date}.log"
|
||||
@@ -41,12 +44,43 @@ class LogManager:
|
||||
log_files.append(log_file)
|
||||
else:
|
||||
log_files = sorted(self.logs_dir.glob("app_*.log"))
|
||||
|
||||
if window_center:
|
||||
# Calculate the 5 months: [center-2, center-1, center, center+1, center+2]
|
||||
allowed_month_years = []
|
||||
cur_m = window_center.month
|
||||
cur_y = window_center.year
|
||||
|
||||
for offset in range(-2, 3):
|
||||
m = cur_m + offset
|
||||
y = cur_y
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
while m > 12:
|
||||
m -= 12
|
||||
y += 1
|
||||
allowed_month_years.append(f"{y}-{m:02d}")
|
||||
|
||||
filtered_files = []
|
||||
for log_path in log_files:
|
||||
try:
|
||||
# Stem is "app_YYYY-MM-DD"
|
||||
file_date_str = log_path.stem.split("_")[1]
|
||||
file_month_year = file_date_str[:7] # YYYY-MM
|
||||
if file_month_year in allowed_month_years:
|
||||
filtered_files.append(log_path)
|
||||
except Exception:
|
||||
continue
|
||||
log_files = filtered_files
|
||||
|
||||
if reverse_files:
|
||||
log_files.reverse()
|
||||
|
||||
# If we only care about hours back, we can optimize file selection
|
||||
if hours_back is not None:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
||||
cutoff_timestamp_str = cutoff_date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
filtered_files = []
|
||||
for log_path in log_files:
|
||||
try:
|
||||
@@ -69,27 +103,20 @@ class LogManager:
|
||||
for log_file in log_files:
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
# For reverse search, we might want to read lines in reverse?
|
||||
# But usually logs are append-only.
|
||||
# If reverse_files is True, we iterate files newest to oldest.
|
||||
# But lines within file are still oldest to newest unless we reverse them.
|
||||
lines = f.readlines()
|
||||
if reverse_files:
|
||||
lines.reverse()
|
||||
lines_iter = reversed(f.readlines()) if reverse_files else f
|
||||
|
||||
for line in lines:
|
||||
for line in lines_iter:
|
||||
try:
|
||||
entry = json.loads(line.strip())
|
||||
|
||||
if cutoff_date:
|
||||
if cutoff_timestamp_str:
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
if not timestamp_str:
|
||||
if (
|
||||
not isinstance(timestamp_str, str)
|
||||
or len(timestamp_str) != 19
|
||||
):
|
||||
continue
|
||||
log_time = datetime.strptime(
|
||||
timestamp_str, "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
log_time = log_time.replace(tzinfo=timezone.utc)
|
||||
if log_time < cutoff_date:
|
||||
if timestamp_str < cutoff_timestamp_str:
|
||||
continue
|
||||
|
||||
yield entry
|
||||
@@ -166,7 +193,7 @@ class LogManager:
|
||||
methods: list[str] | None = None,
|
||||
endpoints: list[str] | None = None,
|
||||
) -> bool:
|
||||
if level and log_data.get("levelname", "").upper() != level.upper():
|
||||
if level and str(log_data.get("levelname", "")).upper() != level.upper():
|
||||
return False
|
||||
|
||||
if request_id and log_data.get("request_id") != request_id:
|
||||
@@ -216,48 +243,108 @@ class LogManager:
|
||||
|
||||
return True
|
||||
|
||||
def _bucket_key_for_timestamp(
|
||||
self, timestamp_str: str, interval_minutes: int
|
||||
) -> str | None:
|
||||
if len(timestamp_str) != 19:
|
||||
return None
|
||||
if timestamp_str[10] != " ":
|
||||
return None
|
||||
|
||||
try:
|
||||
hour = int(timestamp_str[11:13])
|
||||
minute = int(timestamp_str[14:16])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
total_minutes = hour * 60 + minute
|
||||
rounded_minutes = (total_minutes // interval_minutes) * interval_minutes
|
||||
rounded_hour = rounded_minutes // 60
|
||||
rounded_minute = rounded_minutes % 60
|
||||
return f"{timestamp_str[:10]} {rounded_hour:02d}:{rounded_minute:02d}:00"
|
||||
|
||||
def _extract_success_metrics(
|
||||
self, entry: dict[str, Any], message: str
|
||||
) -> tuple[bool, float, int, int]:
|
||||
# Use auth settlement logs as the canonical successful request signal.
|
||||
logger_name = str(entry.get("name", ""))
|
||||
if not logger_name.startswith("routstr.auth"):
|
||||
return False, 0.0, 0, 0
|
||||
|
||||
input_tokens = self._parse_token_count(entry.get("input_tokens", 0))
|
||||
output_tokens = self._parse_token_count(entry.get("output_tokens", 0))
|
||||
|
||||
if "calculated token-based cost" in message:
|
||||
token_cost = entry.get("token_cost", 0)
|
||||
if isinstance(token_cost, (int, float)) and token_cost > 0:
|
||||
return True, float(token_cost), input_tokens, output_tokens
|
||||
return True, 0.0, input_tokens, output_tokens
|
||||
|
||||
if "max cost payment finalized" in message:
|
||||
charged_amount = entry.get("charged_amount", 0)
|
||||
if isinstance(charged_amount, (int, float)) and charged_amount > 0:
|
||||
return True, float(charged_amount), input_tokens, output_tokens
|
||||
return True, 0.0, input_tokens, output_tokens
|
||||
|
||||
return False, 0.0, 0, 0
|
||||
|
||||
def _parse_token_count(self, value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if isinstance(value, float):
|
||||
return max(0, int(value))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
def get_usage_summary(self, hours: int = 24) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
entries = list(
|
||||
self._yield_log_entries(
|
||||
hours_back=hours, window_center=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
return self._calculate_summary_stats(entries)
|
||||
|
||||
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
entries = list(
|
||||
self._yield_log_entries(
|
||||
hours_back=hours, window_center=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
return self._aggregate_metrics_by_time(entries, interval, hours)
|
||||
|
||||
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
|
||||
errors: list[dict] = []
|
||||
# Iterate newest to oldest for errors?
|
||||
# yield_log_entries sorts files by name (date) ascending by default.
|
||||
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
|
||||
# For error details "last N errors", we probably want newest first.
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
# Using list() loads everything into memory, which is what PR 229 did.
|
||||
# For optimization, we could use reverse iterator.
|
||||
for entry in self._yield_log_entries(hours_back=hours):
|
||||
if str(entry.get("levelname", "")).upper() != "ERROR":
|
||||
continue
|
||||
|
||||
# Let's just stick to PR 229 logic which filters 'ERROR' level.
|
||||
errors.append(
|
||||
{
|
||||
"timestamp": entry.get("asctime", ""),
|
||||
"message": entry.get("message", ""),
|
||||
"error_type": entry.get("error_type", "unknown"),
|
||||
"pathname": entry.get("pathname", ""),
|
||||
"lineno": entry.get("lineno", 0),
|
||||
"request_id": entry.get("request_id", ""),
|
||||
}
|
||||
)
|
||||
|
||||
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
|
||||
|
||||
for entry in entries:
|
||||
if entry.get("levelname", "").upper() == "ERROR":
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
errors.append(
|
||||
{
|
||||
"timestamp": timestamp_str,
|
||||
"message": entry.get("message", ""),
|
||||
"error_type": entry.get("error_type", "unknown"),
|
||||
"pathname": entry.get("pathname", ""),
|
||||
"lineno": entry.get("lineno", 0),
|
||||
"request_id": entry.get("request_id", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort reverse time
|
||||
errors.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
errors.sort(key=lambda x: str(x["timestamp"]), reverse=True)
|
||||
return {"errors": errors[:limit], "total_count": len(errors)}
|
||||
|
||||
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
entries = list(
|
||||
self._yield_log_entries(
|
||||
hours_back=hours, window_center=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
|
||||
model_stats: dict[str, dict[str, int | float]] = defaultdict(
|
||||
lambda: {
|
||||
@@ -275,23 +362,22 @@ class LogManager:
|
||||
if not isinstance(model, str):
|
||||
model = "unknown"
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
message = str(entry.get("message", "")).lower()
|
||||
|
||||
if "received proxy request" in message:
|
||||
completed, revenue_msats, _, _ = self._extract_success_metrics(
|
||||
entry, message
|
||||
)
|
||||
if completed:
|
||||
model_stats[model]["requests"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
model_stats[model]["successful"] += 1
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
model_stats[model]["revenue_msats"] += actual_cost
|
||||
if revenue_msats > 0:
|
||||
model_stats[model]["revenue_msats"] += revenue_msats
|
||||
|
||||
if "revert payment" in message or "upstream request failed" in message:
|
||||
failed = (
|
||||
"revert payment" in message or "upstream request failed" in message
|
||||
)
|
||||
if failed:
|
||||
model_stats[model]["requests"] += 1
|
||||
model_stats[model]["failed"] += 1
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
@@ -340,83 +426,16 @@ class LogManager:
|
||||
"total_models": len(models),
|
||||
}
|
||||
|
||||
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
|
||||
stats: dict[str, Any] = {
|
||||
"total_entries": 0,
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"total_errors": 0,
|
||||
"total_warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"unique_models": set(),
|
||||
"error_types": defaultdict(int),
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
stats["total_entries"] += 1
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
level = entry.get("levelname", "").upper()
|
||||
|
||||
if level == "ERROR":
|
||||
stats["total_errors"] += 1
|
||||
if "error_type" in entry:
|
||||
stats["error_types"][str(entry["error_type"])] += 1
|
||||
elif level == "WARNING":
|
||||
stats["total_warnings"] += 1
|
||||
|
||||
if "received proxy request" in message:
|
||||
stats["total_requests"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
stats["successful_chat_completions"] += 1
|
||||
|
||||
if "upstream request failed" in message or "revert payment" in message:
|
||||
stats["failed_requests"] += 1
|
||||
|
||||
if "payment processed successfully" in message:
|
||||
stats["payment_processed"] += 1
|
||||
|
||||
if "upstream" in message and level == "ERROR":
|
||||
stats["upstream_errors"] += 1
|
||||
|
||||
if "model" in entry:
|
||||
model = entry["model"]
|
||||
if isinstance(model, str) and model != "unknown":
|
||||
stats["unique_models"].add(model)
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
stats["revenue_msats"] += float(actual_cost)
|
||||
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
stats["refunds_msats"] += float(max_cost)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _build_summary_response(self, stats: dict[str, Any]) -> dict[str, Any]:
|
||||
revenue_sats = stats["revenue_msats"] / 1000
|
||||
refunds_sats = stats["refunds_msats"] / 1000
|
||||
net_revenue_sats = revenue_sats - refunds_sats
|
||||
|
||||
total_requests = stats["total_requests"]
|
||||
successful = stats["successful_chat_completions"]
|
||||
input_tokens = stats["input_tokens"]
|
||||
output_tokens = stats["output_tokens"]
|
||||
total_tokens = stats["total_tokens"]
|
||||
|
||||
return {
|
||||
"total_entries": stats["total_entries"],
|
||||
@@ -430,6 +449,18 @@ class LogManager:
|
||||
"unique_models_count": len(stats["unique_models"]),
|
||||
"unique_models": sorted(list(stats["unique_models"])),
|
||||
"error_types": dict(stats["error_types"]),
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"avg_input_tokens_per_completion": (
|
||||
input_tokens / successful if successful > 0 else 0
|
||||
),
|
||||
"avg_output_tokens_per_completion": (
|
||||
output_tokens / successful if successful > 0 else 0
|
||||
),
|
||||
"avg_total_tokens_per_completion": (
|
||||
total_tokens / successful if successful > 0 else 0
|
||||
),
|
||||
"success_rate": (successful / total_requests * 100)
|
||||
if total_requests > 0
|
||||
else 0,
|
||||
@@ -449,62 +480,198 @@ class LogManager:
|
||||
),
|
||||
}
|
||||
|
||||
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
|
||||
stats: dict[str, Any] = {
|
||||
"total_entries": 0,
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"total_errors": 0,
|
||||
"total_warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"unique_models": set(),
|
||||
"error_types": defaultdict(int),
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
stats["total_entries"] += 1
|
||||
|
||||
message = str(entry.get("message", "")).lower()
|
||||
level = str(entry.get("levelname", "")).upper()
|
||||
|
||||
if level == "ERROR":
|
||||
stats["total_errors"] += 1
|
||||
if "error_type" in entry:
|
||||
stats["error_types"][str(entry["error_type"])] += 1
|
||||
elif level == "WARNING":
|
||||
stats["total_warnings"] += 1
|
||||
|
||||
completed, revenue_msats, input_tokens, output_tokens = (
|
||||
self._extract_success_metrics(entry, message)
|
||||
)
|
||||
if completed:
|
||||
stats["total_requests"] += 1
|
||||
stats["successful_chat_completions"] += 1
|
||||
stats["input_tokens"] += input_tokens
|
||||
stats["output_tokens"] += output_tokens
|
||||
stats["total_tokens"] += input_tokens + output_tokens
|
||||
|
||||
failed = (
|
||||
"upstream request failed" in message
|
||||
or "revert payment" in message
|
||||
)
|
||||
if failed:
|
||||
stats["total_requests"] += 1
|
||||
stats["failed_requests"] += 1
|
||||
|
||||
if "payment processed successfully" in message:
|
||||
stats["payment_processed"] += 1
|
||||
|
||||
if "upstream" in message and level == "ERROR":
|
||||
stats["upstream_errors"] += 1
|
||||
|
||||
if "model" in entry:
|
||||
model = entry["model"]
|
||||
if isinstance(model, str) and model != "unknown":
|
||||
stats["unique_models"].add(model)
|
||||
|
||||
if completed and revenue_msats > 0:
|
||||
stats["revenue_msats"] += revenue_msats
|
||||
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
stats["refunds_msats"] += float(max_cost)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return self._build_summary_response(stats)
|
||||
|
||||
def _aggregate_metrics_by_time(
|
||||
self, entries: list[dict], interval_minutes: int, hours_back: int
|
||||
) -> dict:
|
||||
time_buckets: dict[str, dict[str, Any]] = defaultdict(
|
||||
lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0}
|
||||
lambda: {
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
if not timestamp_str:
|
||||
if not isinstance(timestamp_str, str):
|
||||
continue
|
||||
|
||||
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
log_time = log_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Round down to nearest interval
|
||||
minutes = log_time.minute
|
||||
rounded_minutes = (minutes // interval_minutes) * interval_minutes
|
||||
bucket_time = log_time.replace(
|
||||
minute=rounded_minutes, second=0, microsecond=0
|
||||
bucket_key = self._bucket_key_for_timestamp(
|
||||
timestamp_str, interval_minutes
|
||||
)
|
||||
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if not bucket_key:
|
||||
continue
|
||||
|
||||
bucket = time_buckets[bucket_key]
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
level = entry.get("levelname", "").upper()
|
||||
message = str(entry.get("message", "")).lower()
|
||||
level = str(entry.get("levelname", "")).upper()
|
||||
|
||||
if "received proxy request" in message:
|
||||
bucket["requests"] += 1
|
||||
completed, revenue_msats, input_tokens, output_tokens = (
|
||||
self._extract_success_metrics(entry, message)
|
||||
)
|
||||
if completed:
|
||||
bucket["total_requests"] += 1
|
||||
bucket["successful_chat_completions"] += 1
|
||||
bucket["input_tokens"] += input_tokens
|
||||
bucket["output_tokens"] += output_tokens
|
||||
bucket["total_tokens"] += input_tokens + output_tokens
|
||||
|
||||
if level == "ERROR":
|
||||
bucket["errors"] += 1
|
||||
if "upstream" in message:
|
||||
bucket["upstream_errors"] += 1
|
||||
elif level == "WARNING":
|
||||
bucket["warnings"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
bucket["revenue_msats"] += float(actual_cost)
|
||||
failed = (
|
||||
"upstream request failed" in message
|
||||
or "revert payment" in message
|
||||
)
|
||||
if failed:
|
||||
bucket["total_requests"] += 1
|
||||
bucket["failed_requests"] += 1
|
||||
|
||||
if "payment processed successfully" in message:
|
||||
bucket["payment_processed"] += 1
|
||||
|
||||
if completed and revenue_msats > 0:
|
||||
bucket["revenue_msats"] += revenue_msats
|
||||
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
bucket["refunds_msats"] += float(max_cost)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
result = []
|
||||
for bucket_key in sorted(time_buckets.keys()):
|
||||
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
|
||||
bucket = dict(time_buckets[bucket_key])
|
||||
# Backward-compatible alias for any callers still reading "requests".
|
||||
bucket["requests"] = bucket["total_requests"]
|
||||
result.append({"timestamp": bucket_key, **bucket})
|
||||
|
||||
totals = {
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
for bucket in result:
|
||||
totals["total_requests"] += int(bucket["total_requests"])
|
||||
totals["successful_chat_completions"] += int(
|
||||
bucket["successful_chat_completions"]
|
||||
)
|
||||
totals["failed_requests"] += int(bucket["failed_requests"])
|
||||
totals["errors"] += int(bucket["errors"])
|
||||
totals["warnings"] += int(bucket["warnings"])
|
||||
totals["payment_processed"] += int(bucket["payment_processed"])
|
||||
totals["upstream_errors"] += int(bucket["upstream_errors"])
|
||||
totals["revenue_msats"] += float(bucket["revenue_msats"])
|
||||
totals["refunds_msats"] += float(bucket["refunds_msats"])
|
||||
totals["input_tokens"] += int(bucket["input_tokens"])
|
||||
totals["output_tokens"] += int(bucket["output_tokens"])
|
||||
totals["total_tokens"] += int(bucket["total_tokens"])
|
||||
|
||||
return {
|
||||
"metrics": result,
|
||||
"interval_minutes": interval_minutes,
|
||||
"hours_back": hours_back,
|
||||
"total_buckets": len(result),
|
||||
"totals": totals,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ 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
|
||||
from ..upstream.auto_topup import periodic_auto_topup
|
||||
from ..wallet import periodic_payout
|
||||
from .admin import admin_router
|
||||
from .db import create_session, init_db, run_migrations
|
||||
@@ -46,6 +48,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
auto_topup_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
@@ -101,6 +105,8 @@ 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())
|
||||
auto_topup_task = asyncio.create_task(periodic_auto_topup())
|
||||
|
||||
yield
|
||||
|
||||
@@ -130,6 +136,10 @@ 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()
|
||||
if auto_topup_task is not None:
|
||||
auto_topup_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
@@ -147,6 +157,10 @@ 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 auto_topup_task is not None:
|
||||
tasks_to_wait.append(auto_topup_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
|
||||
@@ -52,7 +52,7 @@ class Settings(BaseSettings):
|
||||
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
||||
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
|
||||
child_key_cost: int = Field(default=0, env="CHILD_KEY_COST")
|
||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
||||
reset_reserved_balance_on_startup: bool = Field(
|
||||
@@ -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
|
||||
@@ -29,15 +29,13 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
||||
},
|
||||
)
|
||||
elif auth := headers.get("authorization", None):
|
||||
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
|
||||
logger.debug(
|
||||
"Using Authorization header token",
|
||||
"Skipping preflight token balance check for Authorization header",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token
|
||||
"auth_preview": auth[:20] + "..." if len(auth) > 20 else auth,
|
||||
},
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.error("No authentication token provided")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
@@ -75,7 +73,7 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
||||
|
||||
if max_cost_for_model > amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
status_code=402,
|
||||
detail={
|
||||
"reason": "Insufficient balance",
|
||||
"amount_required_msat": max_cost_for_model,
|
||||
@@ -169,9 +167,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"):
|
||||
|
||||
@@ -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,
|
||||
@@ -137,11 +138,6 @@ async def proxy(
|
||||
) -> Response | StreamingResponse:
|
||||
headers = dict(request.headers)
|
||||
|
||||
if "x-cashu" not in headers and "authorization" not in headers.keys():
|
||||
return create_error_response(
|
||||
"unauthorized", "Unauthorized", 401, request=request
|
||||
)
|
||||
|
||||
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
|
||||
request_body = await request.body()
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
@@ -152,6 +148,7 @@ async def proxy(
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
model_obj = get_model_instance(model_id)
|
||||
|
||||
if not model_obj:
|
||||
return create_error_response(
|
||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||
@@ -176,6 +173,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 +206,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"]:
|
||||
@@ -294,9 +296,13 @@ async def proxy(
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
except UpstreamError:
|
||||
# Let the outer UpstreamError handler manage retry/revert
|
||||
raise
|
||||
except Exception as e:
|
||||
# Unexpected error (not an upstream failure) — revert and propagate
|
||||
logger.error(
|
||||
"Upstream request failed, ensuring payment is reverted",
|
||||
"Unexpected error in upstream request, reverting payment",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
@@ -381,10 +387,11 @@ 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 ""
|
||||
parts = auth.split()
|
||||
bearer_key = parts[1] if len(parts) > 1 and parts[0].lower() == "bearer" else ""
|
||||
refund_address = headers.get("Refund-LNURL", None)
|
||||
key_expiry_time = headers.get("Key-Expiry-Time", None)
|
||||
|
||||
@@ -397,6 +404,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 +443,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",
|
||||
|
||||
@@ -10,6 +10,7 @@ from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
@@ -24,6 +25,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
RoutstrUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
157
routstr/upstream/auto_topup.py
Normal file
157
routstr/upstream/auto_topup.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import UpstreamProviderRow, create_session
|
||||
from ..wallet import send_token
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Check every 60 seconds
|
||||
AUTO_TOPUP_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
async def periodic_auto_topup() -> None:
|
||||
"""Background task that monitors Routstr provider balances and auto-tops up when below threshold.
|
||||
|
||||
For each Routstr provider with auto_topup enabled in provider_settings:
|
||||
1. Checks the upstream balance via get_balance()
|
||||
2. If balance < topup_threshold, creates a cashu token from the configured mint
|
||||
3. Sends the token to the upstream provider via topup()
|
||||
"""
|
||||
# Wait for initial startup to complete
|
||||
await asyncio.sleep(30)
|
||||
logger.info("Auto top-up worker started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await _run_auto_topup_cycle()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Auto top-up cycle failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
await asyncio.sleep(AUTO_TOPUP_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def _run_auto_topup_cycle() -> None:
|
||||
"""Single cycle: check all eligible providers and top up if needed."""
|
||||
async with create_session() as session:
|
||||
query = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.provider_type == "routstr",
|
||||
UpstreamProviderRow.enabled == True, # noqa: E712
|
||||
)
|
||||
result = await session.exec(query)
|
||||
providers = result.all()
|
||||
|
||||
for row in providers:
|
||||
try:
|
||||
await _check_and_topup(row)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Auto top-up failed for provider",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"base_url": row.base_url,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
||||
"""Check a single provider's balance and top up if below threshold."""
|
||||
# Parse provider settings
|
||||
settings: dict = {}
|
||||
if row.provider_settings:
|
||||
try:
|
||||
settings = json.loads(row.provider_settings)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
|
||||
if not settings.get("auto_topup"):
|
||||
return
|
||||
|
||||
threshold = settings.get("topup_threshold")
|
||||
amount = settings.get("topup_amount_limit")
|
||||
mint_url = settings.get("topup_mint_url")
|
||||
|
||||
if not threshold or not amount or not mint_url:
|
||||
logger.warning(
|
||||
"Auto top-up enabled but missing configuration",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"has_threshold": bool(threshold),
|
||||
"has_amount": bool(amount),
|
||||
"has_mint": bool(mint_url),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if not row.api_key:
|
||||
return
|
||||
|
||||
# Instantiate provider and check balance
|
||||
provider = RoutstrUpstreamProvider.from_db_row(row)
|
||||
balance = await provider.get_balance()
|
||||
|
||||
if balance is None:
|
||||
logger.warning(
|
||||
"Could not fetch balance for auto top-up",
|
||||
extra={"provider_id": row.id, "base_url": row.base_url},
|
||||
)
|
||||
return
|
||||
|
||||
if balance >= threshold * 1000:
|
||||
return
|
||||
|
||||
# Balance is below threshold - create token and top up
|
||||
logger.info(
|
||||
"Auto top-up triggered",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"balance": balance,
|
||||
"threshold": threshold,
|
||||
"topup_amount": amount,
|
||||
"mint_url": mint_url,
|
||||
},
|
||||
)
|
||||
|
||||
print(amount, mint_url)
|
||||
try:
|
||||
token = await send_token(amount, "sat", mint_url)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create cashu token for auto top-up",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"amount": amount,
|
||||
"mint_url": mint_url,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
result = await provider.topup(token)
|
||||
|
||||
if "error" in result:
|
||||
logger.error(
|
||||
"Auto top-up upstream call failed",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"error": result["error"],
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Auto top-up completed successfully",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"amount": amount,
|
||||
"new_balance_approx": balance + amount,
|
||||
},
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
|
||||
class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
@@ -58,19 +59,52 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def prepare_headers(self, request_headers: dict) -> dict:
|
||||
"""Prepare headers for Azure OpenAI, adding api-key."""
|
||||
headers = super().prepare_headers(request_headers)
|
||||
if self.api_key:
|
||||
headers["api-key"] = self.api_key
|
||||
headers.pop("Authorization", None)
|
||||
headers.pop("authorization", None)
|
||||
return headers
|
||||
|
||||
def prepare_params(
|
||||
self, path: str, query_params: Mapping[str, str] | None
|
||||
) -> Mapping[str, str]:
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version.
|
||||
|
||||
Args:
|
||||
path: Request path
|
||||
query_params: Original query parameters from the client
|
||||
|
||||
Returns:
|
||||
Query parameters dict with Azure API version added for chat completions
|
||||
"""
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version."""
|
||||
params = dict(query_params or {})
|
||||
if path.endswith("chat/completions"):
|
||||
params["api-version"] = self.api_version
|
||||
version = (self.api_version or "").replace("\ufeff", "").strip()
|
||||
if not version or version.lower() == "v1":
|
||||
version = "2024-02-15-preview"
|
||||
params["api-version"] = version
|
||||
return params
|
||||
|
||||
def normalize_request_path(
|
||||
self, path: str, model_obj: "Model | None" = None
|
||||
) -> str:
|
||||
"""Build Azure deployment-specific request path."""
|
||||
clean_path = super().normalize_request_path(path, model_obj).lstrip("/")
|
||||
if model_obj is None:
|
||||
return clean_path
|
||||
|
||||
deployment_id = getattr(
|
||||
model_obj, "canonical_slug", None
|
||||
) or self.transform_model_name(model_obj.id)
|
||||
deployment_id = deployment_id.split("/")[-1]
|
||||
return f"openai/deployments/{deployment_id}/{clean_path}"
|
||||
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: "Model | None" = None
|
||||
) -> str:
|
||||
"""Use endpoint root, stripping accidental /openai/v1 suffix if present."""
|
||||
base_url = self.base_url.rstrip("/")
|
||||
marker = "/openai/v1"
|
||||
if marker in base_url:
|
||||
base_url = base_url.split(marker, 1)[0].rstrip("/")
|
||||
return base_url
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Extract deployment name from model ID."""
|
||||
if "/" in model_id:
|
||||
return model_id.split("/")[-1]
|
||||
return model_id
|
||||
|
||||
@@ -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 ..auth import adjust_payment_for_tokens
|
||||
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
|
||||
@@ -199,6 +197,23 @@ class BaseUpstreamProvider:
|
||||
"""
|
||||
return model_id
|
||||
|
||||
def normalize_request_path(self, path: str, model_obj: Model | None = None) -> str:
|
||||
"""Normalize request path before forwarding to upstream."""
|
||||
if path.startswith("v1/"):
|
||||
return path.replace("v1/", "", 1)
|
||||
return path
|
||||
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: Model | None = None
|
||||
) -> str:
|
||||
"""Get upstream base URL used when building forwarding URL."""
|
||||
return self.base_url.rstrip("/")
|
||||
|
||||
def build_request_url(self, path: str, model_obj: Model | None = None) -> str:
|
||||
"""Build full upstream URL from normalized path."""
|
||||
clean_path = path.lstrip("/")
|
||||
return f"{self.get_request_base_url(path, model_obj)}/{clean_path}"
|
||||
|
||||
def prepare_responses_request_body(
|
||||
self, body: bytes | None, model_obj: Model
|
||||
) -> bytes | None:
|
||||
@@ -427,7 +442,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 +540,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 +558,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 +594,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 +654,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 +848,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 +857,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 +881,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 +971,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",
|
||||
@@ -1024,10 +1118,8 @@ class BaseUpstreamProvider:
|
||||
Returns:
|
||||
Response or StreamingResponse from upstream with cost tracking
|
||||
"""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
url = f"{self.base_url}/{path}"
|
||||
path = self.normalize_request_path(path, model_obj)
|
||||
url = self.build_request_url(path, model_obj)
|
||||
|
||||
transformed_body = self.prepare_request_body(request_body, model_obj)
|
||||
|
||||
@@ -1136,12 +1228,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
|
||||
|
||||
@@ -1202,8 +1294,7 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
|
||||
# Don't revert here — proxy.py owns payment revert to avoid double-revert
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
error_message = "Unable to connect to upstream service"
|
||||
elif isinstance(exc, httpx.TimeoutException):
|
||||
@@ -1233,13 +1324,9 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
|
||||
return create_error_response(
|
||||
"internal_error",
|
||||
"An unexpected server error occurred",
|
||||
500,
|
||||
request=request,
|
||||
# Don't revert here — proxy.py owns payment revert to avoid double-revert
|
||||
raise UpstreamError(
|
||||
"An unexpected server error occurred", status_code=500
|
||||
)
|
||||
|
||||
async def forward_responses_request(
|
||||
@@ -1268,11 +1355,8 @@ class BaseUpstreamProvider:
|
||||
Returns:
|
||||
Response or StreamingResponse from upstream with cost tracking
|
||||
"""
|
||||
# Remove v1/ prefix if present for Responses API
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
url = f"{self.base_url}/{path}"
|
||||
path = self.normalize_request_path(path, model_obj)
|
||||
url = self.build_request_url(path, model_obj)
|
||||
|
||||
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
|
||||
|
||||
@@ -1424,8 +1508,7 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
|
||||
# Don't revert here — proxy.py owns payment revert to avoid double-revert
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
error_message = "Unable to connect to upstream service"
|
||||
elif isinstance(exc, httpx.TimeoutException):
|
||||
@@ -1455,13 +1538,9 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
|
||||
return create_error_response(
|
||||
"internal_error",
|
||||
"An unexpected server error occurred",
|
||||
500,
|
||||
request=request,
|
||||
# Don't revert here — proxy.py owns payment revert to avoid double-revert
|
||||
raise UpstreamError(
|
||||
"An unexpected server error occurred", status_code=500
|
||||
)
|
||||
|
||||
async def forward_get_request(
|
||||
@@ -1480,10 +1559,8 @@ class BaseUpstreamProvider:
|
||||
Returns:
|
||||
StreamingResponse from upstream
|
||||
"""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
url = f"{self.base_url}/{path}"
|
||||
path = self.normalize_request_path(path)
|
||||
url = self.build_request_url(path)
|
||||
|
||||
logger.info(
|
||||
"Forwarding GET request to upstream",
|
||||
@@ -3010,18 +3087,44 @@ 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)
|
||||
|
||||
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
|
||||
# .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")
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
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)
|
||||
|
||||
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]
|
||||
|
||||
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",
|
||||
|
||||
@@ -205,6 +205,9 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if provider:
|
||||
# Keep provider DB id on runtime instance so model mapping can
|
||||
# bind DB overrides to the correct upstream.
|
||||
setattr(provider, "db_id", provider_row.id)
|
||||
await provider.refresh_models_cache()
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
|
||||
@@ -3,13 +3,11 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
@@ -67,38 +65,11 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Strip 'ollama/' prefix for Ollama API compatibility."""
|
||||
return model_id.removeprefix("ollama/")
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Override to use OpenAI-compatible endpoint for proxy requests."""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
original_base_url = self.base_url
|
||||
self.base_url = f"{self.base_url}/v1"
|
||||
|
||||
try:
|
||||
result = await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
self.base_url = original_base_url
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: Model | None = None
|
||||
) -> str:
|
||||
"""Route proxy traffic through Ollama's OpenAI-compatible /v1 endpoint."""
|
||||
return f"{self.base_url.rstrip('/')}/v1"
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from Ollama API using /api/tags endpoint."""
|
||||
|
||||
@@ -283,7 +283,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
print(f"Payload: {payload}", "sending to", url)
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
invoice_data = response.json()
|
||||
|
||||
169
routstr/upstream/routstr.py
Normal file
169
routstr/upstream/routstr.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core import get_logger
|
||||
from ..payment.models import Model
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for communicating with another Routstr instance."""
|
||||
|
||||
provider_type = "routstr"
|
||||
default_base_url = None
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider_fee: float = 1.01,
|
||||
provider_settings: dict | None = None,
|
||||
):
|
||||
"""Initialize Routstr provider.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the upstream Routstr instance
|
||||
api_key: API key for the upstream Routstr instance
|
||||
provider_fee: Provider fee multiplier
|
||||
provider_settings: Provider-specific settings (auto-topup, etc.)
|
||||
"""
|
||||
# Ensure base_url doesn't end with /v1 as BaseUpstreamProvider appends it if needed
|
||||
# but Routstr paths are usually absolute from base.
|
||||
super().__init__(
|
||||
base_url=base_url.rstrip("/"),
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
self.settings = provider_settings or {}
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "RoutstrUpstreamProvider":
|
||||
import json
|
||||
|
||||
settings = {}
|
||||
if provider_row.provider_settings:
|
||||
try:
|
||||
settings = json.loads(provider_row.provider_settings)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
provider_settings=settings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Routstr Node",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": False,
|
||||
"can_topup": True,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Fetch balance from the upstream Routstr node.
|
||||
|
||||
Returns:
|
||||
Balance in satoshis, or None if failed
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/info"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Routstr balance info usually contains 'balance' in msats or sats
|
||||
# Check for msats and convert to sats
|
||||
if "balance_msats" in data:
|
||||
return float(data["balance_msats"]) / 1000.0
|
||||
return float(data.get("balance", 0))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to fetch balance from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
async def topup(self, cashu_token: str) -> dict[str, Any]:
|
||||
"""Top up balance on the upstream Routstr node.
|
||||
|
||||
Args:
|
||||
cashu_token: Cashu token to deposit
|
||||
|
||||
Returns:
|
||||
Dict containing top-up result
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/topup"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
payload = {"cashu_token": cashu_token}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
url, headers=headers, json=payload, timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to topup upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return {"error": str(e)}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from the upstream Routstr node."""
|
||||
url = f"{self.base_url}/v1/models"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers={}, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models = data.get("data", [])
|
||||
return [Model(**m) for m in models]
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to fetch models from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return []
|
||||
|
||||
async def refund_balance(self) -> dict[str, Any]:
|
||||
"""Request a refund from the upstream Routstr node.
|
||||
|
||||
Returns:
|
||||
Dict containing refund result and token
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/refund"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(url, headers=headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to request refund from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return {"error": str(e)}
|
||||
80
tests/integration/test_admin_provider_balance.py
Normal file
80
tests/integration/test_admin_provider_balance.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
async def _create_routstr_provider() -> UpstreamProviderRow:
|
||||
return UpstreamProviderRow(
|
||||
provider_type="routstr",
|
||||
base_url="https://upstream.example",
|
||||
api_key="",
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
token = "test-admin-token"
|
||||
admin_sessions[token] = int(
|
||||
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_provider_balance_timeout_returns_504(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
provider = await _create_routstr_provider()
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
|
||||
timeout_error = httpx.ConnectTimeout("Connect timeout", request=request)
|
||||
|
||||
with patch(
|
||||
"httpx.AsyncHTTPTransport.handle_async_request",
|
||||
new=AsyncMock(side_effect=timeout_error),
|
||||
):
|
||||
response = await integration_client.get(
|
||||
f"/admin/api/upstream-providers/{provider.id}/balance",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 504
|
||||
assert response.json()["detail"] == "Timed out contacting upstream Routstr provider"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_provider_balance_request_error_returns_502(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
provider = await _create_routstr_provider()
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
|
||||
request_error = httpx.ConnectError("Connection failed", request=request)
|
||||
|
||||
with patch(
|
||||
"httpx.AsyncHTTPTransport.handle_async_request",
|
||||
new=AsyncMock(side_effect=request_error),
|
||||
):
|
||||
response = await integration_client.get(
|
||||
f"/admin/api/upstream-providers/{provider.id}/balance",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.json()["detail"] == "Failed to contact upstream Routstr provider"
|
||||
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
|
||||
@@ -267,10 +267,9 @@ async def test_admin_endpoint_unauthenticated(
|
||||
"""Test GET /admin/ endpoint redirects to /"""
|
||||
await db_snapshot.capture()
|
||||
|
||||
response = await integration_client.get("/admin/")
|
||||
response = await integration_client.get("/admin/api/settings")
|
||||
|
||||
assert response.status_code == 307
|
||||
assert response.headers.get("location") == "/"
|
||||
assert response.status_code == 403
|
||||
|
||||
diff = await db_snapshot.diff()
|
||||
assert len(diff["api_keys"]["added"]) == 0
|
||||
|
||||
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
|
||||
@@ -3,13 +3,17 @@ Integration tests for provider management functionality.
|
||||
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
||||
"""
|
||||
|
||||
import time
|
||||
from types import TracebackType
|
||||
from typing import Any, Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from routstr.discovery import _PROVIDERS_CACHE
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import UpstreamProviderRow
|
||||
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
||||
|
||||
from .utils import ResponseValidator
|
||||
|
||||
@@ -71,9 +75,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 +140,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 +215,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 +263,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 +325,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 +395,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 +435,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 +474,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 +507,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 +558,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 +601,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 +654,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
|
||||
@@ -666,3 +682,88 @@ async def test_no_database_changes_during_provider_operations(
|
||||
assert final_diff["api_keys"]["added"] == []
|
||||
assert final_diff["api_keys"]["modified"] == []
|
||||
assert final_diff["api_keys"]["removed"] == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_topup_retries_transient_upstream_failure(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
admin_token = "test-admin-token"
|
||||
admin_sessions[admin_token] = int(time.time()) + 3600
|
||||
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
provider_type="routstr",
|
||||
base_url="https://node.example",
|
||||
api_key="sk-upstream-test",
|
||||
enabled=True,
|
||||
provider_fee=1.01,
|
||||
)
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
class MockResponse:
|
||||
def __init__(self, status_code: int, data: dict[str, Any] | None = None):
|
||||
self.status_code = status_code
|
||||
self._data = data or {}
|
||||
self.text = str(self._data)
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._data
|
||||
|
||||
class MockAsyncClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def __aenter__(self) -> "MockAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
async def post(
|
||||
self, url: str, json: dict[str, Any], headers: dict[str, str]
|
||||
) -> MockResponse:
|
||||
self.calls += 1
|
||||
assert url == "https://node.example/v1/balance/lightning/invoice"
|
||||
assert json["amount_sats"] == 10
|
||||
assert json["purpose"] == "topup"
|
||||
assert json["api_key"] == "sk-upstream-test"
|
||||
assert headers["Authorization"] == "Bearer sk-upstream-test"
|
||||
|
||||
if self.calls == 1:
|
||||
return MockResponse(500, {"detail": "warmup failure"})
|
||||
|
||||
return MockResponse(
|
||||
200,
|
||||
{
|
||||
"bolt11": "lnbc1testinvoice",
|
||||
"invoice_id": "invoice-123",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = MockAsyncClient()
|
||||
|
||||
try:
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
response = await integration_client.post(
|
||||
f"/admin/api/upstream-providers/{provider.id}/topup",
|
||||
json={"amount": 10},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
|
||||
assert data["topup_data"]["invoice_id"] == "invoice-123"
|
||||
assert mock_client.calls == 2
|
||||
finally:
|
||||
admin_sessions.pop(admin_token, None)
|
||||
|
||||
@@ -171,10 +171,11 @@ async def test_proxy_get_unauthorized_access(integration_client: AsyncClient) ->
|
||||
assert response.status_code == 200 # GET requests are allowed
|
||||
|
||||
# Test 2: POST requests without auth should return 401
|
||||
# Note: Model validation happens before auth, so missing model returns 400
|
||||
response = await integration_client.post(
|
||||
"/v1/chat/completions", json={"test": "data"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.status_code in [400, 401] # Accept both for now
|
||||
|
||||
# Test 3: POST with invalid API key
|
||||
# Note: After refactor, model validation may happen before auth validation
|
||||
@@ -550,9 +551,6 @@ async def test_proxy_get_concurrent_requests(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_get_response_format_preservation(
|
||||
|
||||
@@ -133,13 +133,16 @@ async def test_reserved_balance_with_successful_requests(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_reserved_balance_for_revert(
|
||||
async def test_revert_with_zero_reserved_balance_is_noop(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
|
||||
"""Test that revert_pay_for_request is a no-op when reserved_balance is 0.
|
||||
|
||||
Previously this would drive reserved_balance negative. With the floor guard,
|
||||
it should return False and leave reserved_balance at 0.
|
||||
"""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
# Create key with zero reserved balance
|
||||
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
@@ -149,17 +152,211 @@ async def test_insufficient_reserved_balance_for_revert(
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Try to revert more than available
|
||||
# Note: Current implementation allows reserved_balance to go negative
|
||||
await revert_pay_for_request(test_key, integration_session, 100)
|
||||
# Try to revert more than available — should be a no-op
|
||||
result = await revert_pay_for_request(test_key, integration_session, 100)
|
||||
|
||||
# Refresh to get updated values
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
# Current implementation allows negative reserved balance
|
||||
assert test_key.reserved_balance == -100, (
|
||||
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
|
||||
assert result is False, "Revert should return False when reservation already released"
|
||||
assert test_key.reserved_balance == 0, (
|
||||
f"Reserved balance should remain 0, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == -1, (
|
||||
f"Expected total_requests to be -1, got: {test_key.total_requests}"
|
||||
assert test_key.total_requests == 0, (
|
||||
f"Total requests should remain 0, got: {test_key.total_requests}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revert_with_sufficient_reserved_balance_succeeds(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that revert_pay_for_request works correctly when there is enough reserved balance."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
unique_key = f"test_revert_ok_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=5000,
|
||||
reserved_balance=500,
|
||||
total_requests=3,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
result = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
assert result is True, "Revert should return True on success"
|
||||
assert test_key.reserved_balance == 0, (
|
||||
f"Reserved balance should be 0, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == 2, (
|
||||
f"Total requests should be 2, got: {test_key.total_requests}"
|
||||
)
|
||||
assert test_key.balance == 5000, "Balance should not change on revert"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revert_partial_reserved_balance_is_noop(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that reverting more than the current reserved_balance is a no-op."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
unique_key = f"test_revert_partial_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=5000,
|
||||
reserved_balance=50,
|
||||
total_requests=1,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Try to revert 500 when only 50 is reserved — should be no-op
|
||||
result = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
assert result is False, "Revert should fail when cost > reserved_balance"
|
||||
assert test_key.reserved_balance == 50, (
|
||||
f"Reserved balance should stay at 50, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == 1, (
|
||||
f"Total requests should stay at 1, got: {test_key.total_requests}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_revert_prevented(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that calling revert twice doesn't drive reserved_balance negative.
|
||||
|
||||
This simulates the double-revert scenario where both upstream/base.py
|
||||
and proxy.py attempt to revert the same reservation.
|
||||
"""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
unique_key = f"test_double_revert_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=10000,
|
||||
reserved_balance=500,
|
||||
total_requests=5,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# First revert — should succeed
|
||||
result1 = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
assert result1 is True
|
||||
assert test_key.reserved_balance == 0
|
||||
assert test_key.total_requests == 4
|
||||
|
||||
# Second revert of the same amount — should be no-op
|
||||
result2 = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
assert result2 is False, "Second revert should be a no-op"
|
||||
assert test_key.reserved_balance == 0, (
|
||||
f"Reserved balance should stay 0, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == 4, (
|
||||
f"Total requests should stay 4, got: {test_key.total_requests}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequential_reverts_never_go_negative(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that multiple reverts don't cause negative reserved_balance.
|
||||
|
||||
Simulates the double-revert scenario where multiple code paths
|
||||
attempt to revert the same reservation.
|
||||
"""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
unique_key = f"test_multi_revert_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=10000,
|
||||
reserved_balance=500,
|
||||
total_requests=5,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Run 5 sequential reverts for the same 500 reservation
|
||||
results = []
|
||||
for _ in range(5):
|
||||
r = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
results.append(r)
|
||||
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
# Exactly one should succeed, rest should be no-ops
|
||||
success_count = sum(1 for r in results if r is True)
|
||||
assert success_count == 1, (
|
||||
f"Exactly one revert should succeed, got {success_count} successes"
|
||||
)
|
||||
assert test_key.reserved_balance == 0, (
|
||||
f"Reserved balance should be 0, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {test_key.reserved_balance}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_key_revert_floor_guard(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that child key reserved_balance also has floor guard on revert."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
parent_key_hash = f"test_parent_{uuid.uuid4().hex[:8]}"
|
||||
child_key_hash = f"test_child_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
parent_key = ApiKey(
|
||||
hashed_key=parent_key_hash,
|
||||
balance=10000,
|
||||
reserved_balance=500,
|
||||
total_requests=3,
|
||||
)
|
||||
child_key = ApiKey(
|
||||
hashed_key=child_key_hash,
|
||||
balance=0,
|
||||
reserved_balance=500,
|
||||
total_requests=3,
|
||||
parent_key_hash=parent_key_hash,
|
||||
)
|
||||
integration_session.add(parent_key)
|
||||
integration_session.add(child_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# First revert succeeds
|
||||
result1 = await revert_pay_for_request(child_key, integration_session, 500)
|
||||
await integration_session.refresh(parent_key)
|
||||
await integration_session.refresh(child_key)
|
||||
|
||||
assert result1 is True
|
||||
assert parent_key.reserved_balance == 0
|
||||
assert child_key.reserved_balance == 0
|
||||
|
||||
# Second revert is a no-op for both parent and child
|
||||
result2 = await revert_pay_for_request(child_key, integration_session, 500)
|
||||
await integration_session.refresh(parent_key)
|
||||
await integration_session.refresh(child_key)
|
||||
|
||||
assert result2 is False
|
||||
assert parent_key.reserved_balance == 0, (
|
||||
f"Parent reserved_balance should stay 0, got: {parent_key.reserved_balance}"
|
||||
)
|
||||
assert child_key.reserved_balance == 0, (
|
||||
f"Child reserved_balance should stay 0, got: {child_key.reserved_balance}"
|
||||
)
|
||||
|
||||
@@ -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,14 +1,18 @@
|
||||
"""Tests for the model prioritization algorithm."""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
# Set required env vars before importing
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.algorithm import ( # noqa: E402
|
||||
calculate_model_cost_score,
|
||||
create_model_mappings,
|
||||
get_provider_penalty,
|
||||
)
|
||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||
@@ -45,11 +49,21 @@ def create_test_model(
|
||||
)
|
||||
|
||||
|
||||
def create_test_provider(name: str, base_url: str = "http://test.com") -> Mock:
|
||||
def create_test_provider(
|
||||
name: str,
|
||||
base_url: str = "http://test.com",
|
||||
*,
|
||||
db_id: int | None = None,
|
||||
models: list[Model] | None = None,
|
||||
upstream_name: str | None = None,
|
||||
) -> Mock:
|
||||
"""Helper to create a test provider mock."""
|
||||
provider = Mock()
|
||||
provider.provider_type = name
|
||||
provider.base_url = base_url
|
||||
provider.db_id = db_id
|
||||
provider.upstream_name = upstream_name or name
|
||||
provider.get_cached_models.return_value = models or []
|
||||
return provider
|
||||
|
||||
|
||||
@@ -99,3 +113,80 @@ def test_get_provider_penalty_openrouter() -> None:
|
||||
provider = create_test_provider("openrouter", "https://openrouter.ai/api/v1")
|
||||
penalty = get_provider_penalty(provider)
|
||||
assert penalty == 1.001
|
||||
|
||||
|
||||
def test_create_model_mappings_includes_db_override_for_missing_cached_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Model overrides should still map when provider discovery misses the model."""
|
||||
provider = create_test_provider(
|
||||
"azure",
|
||||
"https://example.openai.azure.com/openai/v1",
|
||||
db_id=7,
|
||||
models=[],
|
||||
)
|
||||
override_model = create_test_model("azure/gpt-4o")
|
||||
override_model.canonical_slug = "azure-deployment"
|
||||
|
||||
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
|
||||
return override_model
|
||||
|
||||
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
|
||||
|
||||
override_row = SimpleNamespace(id="azure/gpt-4o", upstream_provider_id=7, enabled=True)
|
||||
|
||||
model_instances, provider_map, unique_models = create_model_mappings(
|
||||
upstreams=[provider],
|
||||
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
|
||||
disabled_model_ids=set(),
|
||||
)
|
||||
|
||||
assert "azure/gpt-4o" in model_instances
|
||||
assert provider_map["azure/gpt-4o"] == [provider]
|
||||
assert "gpt-4o" in unique_models
|
||||
|
||||
|
||||
def test_create_model_mappings_dedupes_with_provider_identity_not_provider_type(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Different provider instances of same type should both survive dedupe."""
|
||||
provider_a_model = create_test_model(
|
||||
"azure/gpt-4o", prompt_price=0.01, completion_price=0.01
|
||||
)
|
||||
provider_a = create_test_provider(
|
||||
"azure",
|
||||
"https://a.openai.azure.com/openai/v1",
|
||||
db_id=1,
|
||||
models=[provider_a_model],
|
||||
upstream_name="azure-a",
|
||||
)
|
||||
provider_b = create_test_provider(
|
||||
"azure",
|
||||
"https://b.openai.azure.com/openai/v1",
|
||||
db_id=2,
|
||||
models=[],
|
||||
upstream_name="azure-b",
|
||||
)
|
||||
|
||||
override_model = create_test_model(
|
||||
"azure/gpt-4o", prompt_price=0.001, completion_price=0.001
|
||||
)
|
||||
override_model.canonical_slug = "azure-b-deployment"
|
||||
|
||||
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
|
||||
return override_model
|
||||
|
||||
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
|
||||
|
||||
override_row = SimpleNamespace(id="azure/gpt-4o", upstream_provider_id=2, enabled=True)
|
||||
|
||||
_, provider_map, _ = create_model_mappings(
|
||||
upstreams=[provider_a, provider_b],
|
||||
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
|
||||
disabled_model_ids=set(),
|
||||
)
|
||||
|
||||
providers_for_alias = provider_map["azure/gpt-4o"]
|
||||
assert provider_a in providers_for_alias
|
||||
assert provider_b in providers_for_alias
|
||||
assert len(providers_for_alias) == 2
|
||||
|
||||
82
tests/unit/test_upstream_azure.py
Normal file
82
tests/unit/test_upstream_azure.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Tests for Azure upstream provider request normalization."""
|
||||
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
from routstr.upstream.azure import AzureUpstreamProvider
|
||||
|
||||
|
||||
def create_test_model(model_id: str, canonical_slug: str | None = None) -> Model:
|
||||
return Model(
|
||||
id=model_id,
|
||||
name=model_id,
|
||||
created=0,
|
||||
description="test",
|
||||
context_length=8192,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="test",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.001,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
canonical_slug=canonical_slug,
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_headers_uses_azure_api_key_header() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
|
||||
headers = provider.prepare_headers({"Authorization": "Bearer user-token"})
|
||||
|
||||
assert headers["api-key"] == "azure-key"
|
||||
assert "Authorization" not in headers
|
||||
assert "authorization" not in headers
|
||||
|
||||
|
||||
def test_prepare_params_normalizes_azure_api_version() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com",
|
||||
api_key="azure-key",
|
||||
api_version="\ufeff v1 ",
|
||||
)
|
||||
|
||||
params = provider.prepare_params("chat/completions", {})
|
||||
|
||||
assert params["api-version"] == "2024-02-15-preview"
|
||||
|
||||
|
||||
def test_normalize_request_path_includes_deployment_id() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com/openai/v1",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
model = create_test_model("azure/gpt-4o", canonical_slug="deploy-gpt4o")
|
||||
|
||||
path = provider.normalize_request_path("v1/chat/completions", model)
|
||||
|
||||
assert path == "openai/deployments/deploy-gpt4o/chat/completions"
|
||||
|
||||
|
||||
def test_get_request_base_url_strips_openai_v1_suffix() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com/openai/v1",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
model = create_test_model("azure/gpt-4o")
|
||||
|
||||
base_url = provider.get_request_base_url("chat/completions", model)
|
||||
|
||||
assert base_url == "https://example.openai.azure.com"
|
||||
76
tests/unit/test_upstream_routstr.py
Normal file
76
tests/unit/test_upstream_routstr.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from types import TracebackType
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.routstr import RoutstrUpstreamProvider
|
||||
|
||||
|
||||
class DummyAsyncClient:
|
||||
def __init__(self, response: Mock | None = None, error: Exception | None = None):
|
||||
self.response = response
|
||||
self.error = error
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
async def __aenter__(self) -> "DummyAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def get(
|
||||
self, url: str, headers: dict[str, str], timeout: float
|
||||
) -> Mock:
|
||||
self.calls.append({"url": url, "headers": headers, "timeout": timeout})
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
assert self.response is not None
|
||||
return self.response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_omits_auth_header_when_api_key_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.json.return_value = {"balance_msats": 42000}
|
||||
response.raise_for_status.return_value = None
|
||||
|
||||
client = DummyAsyncClient(response=response)
|
||||
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
|
||||
|
||||
provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="")
|
||||
|
||||
balance = await provider.get_balance()
|
||||
|
||||
assert balance == 42.0
|
||||
assert client.calls == [
|
||||
{
|
||||
"url": "https://node.example/v1/balance/info",
|
||||
"headers": {},
|
||||
"timeout": 10.0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_returns_none_on_connect_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out"))
|
||||
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
|
||||
|
||||
provider = RoutstrUpstreamProvider(
|
||||
base_url="https://node.example",
|
||||
api_key="secret",
|
||||
)
|
||||
|
||||
balance = await provider.get_balance()
|
||||
|
||||
assert balance is None
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next",
|
||||
"next/core-web-vitals",
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"plugins": ["react", "@typescript-eslint"]
|
||||
}
|
||||
1
ui/.gitignore
vendored
1
ui/.gitignore
vendored
@@ -42,3 +42,4 @@ next-env.d.ts
|
||||
|
||||
# favicon conflicts
|
||||
/app/favicon.ico
|
||||
.pnpm-store/
|
||||
|
||||
5
ui/.prettierignore
Normal file
5
ui/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
pnpm-lock.yaml
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
next-env.d.ts
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
import { useCurrencyStore } from '@/lib/stores/currency';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
|
||||
import { TemporaryBalances } from '@/components/temporary-balances';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
|
||||
export default function BalancesPage() {
|
||||
const { displayUnit } = useCurrencyStore();
|
||||
@@ -22,39 +21,26 @@ export default function BalancesPage() {
|
||||
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor and manage wallet balances
|
||||
</p>
|
||||
</div>
|
||||
{/* Global currency toggle is now in SiteHeader */}
|
||||
</div>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='Balances'
|
||||
description='Monitor and manage wallet balances across cashu mints and temporary stores.'
|
||||
/>
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<div className='col-span-full'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-6'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,133 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:is(.dark *, .red *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--font-geist-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
|
||||
Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
.red {
|
||||
--background: oklch(0.14 0.03 24);
|
||||
--foreground: oklch(0.9 0.05 28);
|
||||
--card: oklch(0.17 0.04 24);
|
||||
--card-foreground: oklch(0.9 0.05 28);
|
||||
--popover: oklch(0.17 0.04 24);
|
||||
--popover-foreground: oklch(0.9 0.05 28);
|
||||
--primary: oklch(0.78 0.14 25);
|
||||
--primary-foreground: oklch(0.14 0.03 24);
|
||||
--secondary: oklch(0.22 0.05 24);
|
||||
--secondary-foreground: oklch(0.9 0.05 28);
|
||||
--muted: oklch(0.22 0.05 24);
|
||||
--muted-foreground: oklch(0.72 0.04 26);
|
||||
--accent: oklch(0.25 0.08 25);
|
||||
--accent-foreground: oklch(0.92 0.05 28);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(0.72 0.11 25 / 24%);
|
||||
--input: oklch(0.75 0.12 25 / 28%);
|
||||
--ring: oklch(0.62 0.12 24);
|
||||
}
|
||||
|
||||
html.red {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-border: var(--border);
|
||||
--color-card: var(--card);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -21,194 +141,34 @@
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--font-serif: Georgia, serif;
|
||||
--radius: 0.5rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--spacing: var(--spacing);
|
||||
--letter-spacing: var(--letter-spacing);
|
||||
--shadow-offset-y: var(--shadow-offset-y);
|
||||
--shadow-offset-x: var(--shadow-offset-x);
|
||||
--shadow-spread: var(--shadow-spread);
|
||||
--shadow-blur: var(--shadow-blur);
|
||||
--shadow-opacity: var(--shadow-opacity);
|
||||
--color-shadow-color: var(--shadow-color);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.99 0 0);
|
||||
--foreground: oklch(0 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0 0 0);
|
||||
--popover: oklch(0.99 0 0);
|
||||
--popover-foreground: oklch(0 0 0);
|
||||
--primary: oklch(0 0 0);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.94 0 0);
|
||||
--secondary-foreground: oklch(0 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.44 0 0);
|
||||
--accent: oklch(0.94 0 0);
|
||||
--accent-foreground: oklch(0 0 0);
|
||||
--destructive: oklch(0.63 0.19 23.03);
|
||||
--border: oklch(0.92 0 0);
|
||||
--input: oklch(0.94 0 0);
|
||||
--ring: oklch(0 0 0);
|
||||
--chart-1: oklch(0.81 0.17 75.35);
|
||||
--chart-2: oklch(0.55 0.22 264.53);
|
||||
--chart-3: oklch(0.72 0 0);
|
||||
--chart-4: oklch(0.92 0 0);
|
||||
--chart-5: oklch(0.56 0 0);
|
||||
--sidebar: oklch(0.99 0 0);
|
||||
--sidebar-foreground: oklch(0 0 0);
|
||||
--sidebar-primary: oklch(0 0 0);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.94 0 0);
|
||||
--sidebar-accent-foreground: oklch(0 0 0);
|
||||
--sidebar-border: oklch(0.94 0 0);
|
||||
--sidebar-ring: oklch(0 0 0);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-blur: 2px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 1px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
||||
--tracking-normal: 0em;
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(1 0 0);
|
||||
--card: oklch(0.14 0 0);
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0.18 0 0);
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
--primary: oklch(1 0 0);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.25 0 0);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.23 0 0);
|
||||
--muted-foreground: oklch(0.72 0 0);
|
||||
--accent: oklch(0.32 0 0);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.69 0.2 23.91);
|
||||
--border: oklch(0.26 0 0);
|
||||
--input: oklch(0.32 0 0);
|
||||
--ring: oklch(0.72 0 0);
|
||||
--chart-1: oklch(0.81 0.17 75.35);
|
||||
--chart-2: oklch(0.58 0.21 260.84);
|
||||
--chart-3: oklch(0.56 0 0);
|
||||
--chart-4: oklch(0.44 0 0);
|
||||
--chart-5: oklch(0.92 0 0);
|
||||
--sidebar: oklch(0.18 0 0);
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-primary: oklch(1 0 0);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.32 0 0);
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(0.32 0 0);
|
||||
--sidebar-ring: oklch(0.72 0 0);
|
||||
--destructive-foreground: oklch(0 0 0);
|
||||
--radius: 0.5rem;
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-blur: 2px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 1px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
||||
html,
|
||||
body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
button,
|
||||
[type='button'],
|
||||
[type='submit'],
|
||||
[type='reset'],
|
||||
[role='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Routstr',
|
||||
description: 'Routstr model management',
|
||||
@@ -34,7 +21,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<SuppressHydrationWarning>
|
||||
<Providers>{children}</Providers>
|
||||
|
||||
@@ -5,16 +5,10 @@ import type { ChangeEvent, FormEvent, ReactElement } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { adminLogin } from '@/lib/api/services/auth';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { toast } from 'sonner';
|
||||
import { AuthPageShell } from '@/components/auth-page-shell';
|
||||
|
||||
export default function AdminLoginPage(): ReactElement {
|
||||
const router = useRouter();
|
||||
@@ -78,51 +72,42 @@ export default function AdminLoginPage(): ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
|
||||
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='text-center text-2xl font-bold'>
|
||||
Admin Login
|
||||
</CardTitle>
|
||||
<CardDescription className='text-center'>
|
||||
Enter your admin password to access the dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{allowCustomBaseUrl && (
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='API URL (https://api.example.com)'
|
||||
value={baseUrl}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setBaseUrl(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Admin Password'
|
||||
value={password}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<AuthPageShell
|
||||
title='Admin Login'
|
||||
description='Enter your admin password to access the dashboard.'
|
||||
>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{allowCustomBaseUrl && (
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='API URL (https://api.example.com)'
|
||||
value={baseUrl}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setBaseUrl(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Admin Password'
|
||||
value={password}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,18 +10,8 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface LogEntry {
|
||||
asctime: string;
|
||||
name: string;
|
||||
levelname: string;
|
||||
message: string;
|
||||
pathname: string;
|
||||
lineno: number;
|
||||
version: string;
|
||||
request_id: string;
|
||||
[key: string]: string | number | object | undefined;
|
||||
}
|
||||
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogDetailsDialogProps {
|
||||
log: LogEntry | null;
|
||||
@@ -29,24 +19,6 @@ interface LogDetailsDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogDetailsDialog({
|
||||
log,
|
||||
isOpen,
|
||||
@@ -79,10 +51,13 @@ export function LogDetailsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
|
||||
<DialogContent className='max-h-[92svh] w-full max-w-none overflow-hidden md:max-w-4xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className={getLevelColor(log.levelname)}>
|
||||
<Badge
|
||||
variant={getLogLevelBadgeVariant(log.levelname)}
|
||||
className='uppercase'
|
||||
>
|
||||
{log.levelname}
|
||||
</Badge>
|
||||
<span>Log Entry Details</span>
|
||||
@@ -92,7 +67,7 @@ export function LogDetailsDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
|
||||
<ScrollArea className='h-[70svh] w-full overflow-x-auto sm:h-[75vh]'>
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
interface LogEntry {
|
||||
asctime: string;
|
||||
name: string;
|
||||
levelname: string;
|
||||
message: string;
|
||||
pathname: string;
|
||||
lineno: number;
|
||||
version: string;
|
||||
request_id: string;
|
||||
[key: string]: string | number | object | undefined;
|
||||
}
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogEntryCardProps {
|
||||
entry: LogEntry;
|
||||
onClick: (entry: LogEntry) => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
|
||||
const extraFields = Object.keys(entry).filter(
|
||||
(key) =>
|
||||
@@ -50,73 +22,58 @@ export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
|
||||
'request_id',
|
||||
].includes(key)
|
||||
);
|
||||
const hasRequestId =
|
||||
Boolean(entry.request_id) && entry.request_id !== 'no-request-id';
|
||||
const shortPath = entry.pathname.split('/').pop() || entry.pathname;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
|
||||
className='bg-card hover:bg-accent/35 group mb-2 cursor-pointer rounded-lg border p-2.5 transition-colors duration-150 sm:p-3'
|
||||
onClick={() => onClick(entry)}
|
||||
>
|
||||
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline' className={getLevelColor(entry.levelname)}>
|
||||
{entry.levelname}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
|
||||
{entry.asctime}
|
||||
</span>
|
||||
<Badge variant='secondary' className='truncate text-xs'>
|
||||
{entry.name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<div className='text-muted-foreground truncate text-xs'>
|
||||
{entry.pathname}:{entry.lineno}
|
||||
</div>
|
||||
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
|
||||
{entry.message}
|
||||
</div>
|
||||
|
||||
{entry.request_id && entry.request_id !== 'no-request-id' && (
|
||||
<div className='mb-2 min-w-0'>
|
||||
<div className='inline-block max-w-full'>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
|
||||
Request ID: {entry.request_id}
|
||||
</span>
|
||||
<div className='flex min-w-0 items-start justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1 space-y-1.5'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
|
||||
<Badge
|
||||
variant={getLogLevelBadgeVariant(entry.levelname)}
|
||||
className='h-5 px-1.5 text-[10px] uppercase'
|
||||
>
|
||||
{entry.levelname}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground text-[11px]'>
|
||||
{entry.asctime}
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-5 max-w-[11rem] truncate px-1.5 text-[10px]'
|
||||
>
|
||||
{entry.name}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extraFields.length > 0 && (
|
||||
<div className='mt-3 min-w-0 border-t pt-3'>
|
||||
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
|
||||
<div className='grid grid-cols-1 gap-2'>
|
||||
{extraFields.slice(0, 4).map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className='min-w-0 overflow-hidden text-xs break-words'
|
||||
>
|
||||
<span className='font-medium break-all'>{key}:</span>{' '}
|
||||
<span className='text-muted-foreground break-all'>
|
||||
{typeof entry[key] === 'object'
|
||||
? JSON.stringify(entry[key])
|
||||
: String(entry[key])}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{extraFields.length > 4 && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
...and {extraFields.length - 4} more fields
|
||||
</div>
|
||||
)}
|
||||
<p className='line-clamp-1 font-mono text-xs break-words sm:text-sm'>
|
||||
{entry.message}
|
||||
</p>
|
||||
|
||||
<div className='text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-[11px]'>
|
||||
{hasRequestId ? (
|
||||
<span className='inline-block max-w-[14rem] truncate rounded border px-1.5 py-0.5 font-mono text-[10px] sm:max-w-[20rem]'>
|
||||
{entry.request_id}
|
||||
</span>
|
||||
) : null}
|
||||
<span className='truncate'>
|
||||
{shortPath}:{entry.lineno}
|
||||
</span>
|
||||
{extraFields.length > 0 ? (
|
||||
<span>{extraFields.length} extra</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='pt-0.5'>
|
||||
<ChevronRight className='text-muted-foreground h-4 w-4 opacity-50 transition-opacity group-hover:opacity-90' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -21,20 +29,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MultiSelectCommandFilter } from './multi-select-command-filter';
|
||||
|
||||
interface LogFiltersProps {
|
||||
selectedDate: string;
|
||||
@@ -97,32 +93,12 @@ const ENDPOINT_OPTIONS = [
|
||||
'/embeddings/models',
|
||||
];
|
||||
|
||||
interface FilterBadgeProps {
|
||||
value: string;
|
||||
onRemove: (value: string) => void;
|
||||
}
|
||||
|
||||
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='flex items-center gap-1 px-1 font-normal'
|
||||
>
|
||||
{value}
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove(value);
|
||||
}}
|
||||
className='hover:bg-muted-foreground/20 rounded-full'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const STATUS_4XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
|
||||
code.startsWith('4')
|
||||
);
|
||||
const STATUS_5XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
|
||||
code.startsWith('5')
|
||||
);
|
||||
|
||||
export function LogFilters({
|
||||
selectedDate,
|
||||
@@ -151,7 +127,7 @@ export function LogFilters({
|
||||
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
|
||||
const [date, setDate] = useState<Date | undefined>(
|
||||
selectedDate && selectedDate !== 'all'
|
||||
? new Date(selectedDate + 'T00:00:00')
|
||||
? new Date(`${selectedDate}T00:00:00`)
|
||||
: undefined
|
||||
);
|
||||
|
||||
@@ -162,6 +138,7 @@ export function LogFilters({
|
||||
useEffect(() => {
|
||||
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
||||
setIsCustom(!currentIsPreset);
|
||||
|
||||
if (!currentIsPreset) {
|
||||
setCustomLimit(limit.toString());
|
||||
}
|
||||
@@ -170,88 +147,79 @@ export function LogFilters({
|
||||
useEffect(() => {
|
||||
if (selectedDate === 'all' || !selectedDate) {
|
||||
setDate(undefined);
|
||||
} else {
|
||||
const d = new Date(selectedDate + 'T00:00:00');
|
||||
setDate(isNaN(d.getTime()) ? undefined : d);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(`${selectedDate}T00:00:00`);
|
||||
setDate(Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate);
|
||||
}, [selectedDate]);
|
||||
|
||||
const handleLimitChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setIsCustom(true);
|
||||
setCustomLimit(limit.toString());
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(Number(value));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(Number(value));
|
||||
};
|
||||
|
||||
const handleCustomLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setCustomLimit(value);
|
||||
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomLimit(event.target.value);
|
||||
};
|
||||
|
||||
const handleCustomLimitApply = () => {
|
||||
const numValue = parseInt(customLimit);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
onLimitChange(numValue);
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(100);
|
||||
const numericValue = Number.parseInt(customLimit, 10);
|
||||
|
||||
if (!Number.isNaN(numericValue) && numericValue > 0) {
|
||||
onLimitChange(numericValue);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(100);
|
||||
};
|
||||
|
||||
const handleCustomLimitKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === 'Enter') {
|
||||
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleCustomLimitApply();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
setDate(selectedDate);
|
||||
if (selectedDate) {
|
||||
onDateChange(format(selectedDate, 'yyyy-MM-dd'));
|
||||
} else {
|
||||
onDateChange('all');
|
||||
}
|
||||
};
|
||||
const handleDateSelect = (nextDate: Date | undefined) => {
|
||||
setDate(nextDate);
|
||||
|
||||
const toggleSelection = (
|
||||
current: string[],
|
||||
value: string,
|
||||
onChange: (val: string[]) => void
|
||||
) => {
|
||||
if (current.includes(value)) {
|
||||
onChange(current.filter((v) => v !== value));
|
||||
} else {
|
||||
onChange([...current, value]);
|
||||
if (nextDate) {
|
||||
onDateChange(format(nextDate, 'yyyy-MM-dd'));
|
||||
return;
|
||||
}
|
||||
|
||||
onDateChange('all');
|
||||
};
|
||||
|
||||
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
|
||||
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
|
||||
const newSelection = new Set([...selectedStatusCodes]);
|
||||
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
|
||||
const rangeCodes = range === '4xx' ? STATUS_4XX_CODES : STATUS_5XX_CODES;
|
||||
const nextSelection = new Set(selectedStatusCodes);
|
||||
const allSelected = rangeCodes.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
);
|
||||
|
||||
if (allIncluded) {
|
||||
codes.forEach((c) => newSelection.delete(c));
|
||||
if (allSelected) {
|
||||
rangeCodes.forEach((code) => nextSelection.delete(code));
|
||||
} else {
|
||||
codes.forEach((c) => newSelection.add(c));
|
||||
rangeCodes.forEach((code) => nextSelection.add(code));
|
||||
}
|
||||
onStatusCodesChange(Array.from(newSelection));
|
||||
|
||||
onStatusCodesChange(Array.from(nextSelection));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Filter className='h-5 w-5' />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>
|
||||
Filter logs by date, level, request ID, text search, status code,
|
||||
method, endpoint and limit
|
||||
@@ -314,336 +282,62 @@ export function LogFilters({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>Status Codes</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selectedStatusCodes.length > 0 ? (
|
||||
selectedStatusCodes.map((code) => (
|
||||
<FilterBadge
|
||||
key={code}
|
||||
value={code}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
val,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>All codes</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-64 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add status code...'
|
||||
value={statusSearch}
|
||||
onValueChange={setStatusSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedStatusCodes.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedStatusCodes.map((code) => (
|
||||
<CommandItem
|
||||
key={`selected-${code}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
code,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{code}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{statusSearch &&
|
||||
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
|
||||
!selectedStatusCodes.includes(statusSearch) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
if (/^\d+$/.test(statusSearch)) {
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
statusSearch,
|
||||
onStatusCodesChange
|
||||
);
|
||||
setStatusSearch('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{statusSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading='Quick Filters'>
|
||||
<CommandItem
|
||||
onSelect={() => handleQuickStatusCode('4xx')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||
c.startsWith('4')
|
||||
).every((c) => selectedStatusCodes.includes(c))}
|
||||
className='mr-2'
|
||||
/>
|
||||
4xx Errors
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => handleQuickStatusCode('5xx')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||
c.startsWith('5')
|
||||
).every((c) => selectedStatusCodes.includes(c))}
|
||||
className='mr-2'
|
||||
/>
|
||||
5xx Errors
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading='Common Codes'>
|
||||
{STATUS_CODE_OPTIONS.filter(
|
||||
(code) => !selectedStatusCodes.includes(code)
|
||||
).map((code) => (
|
||||
<CommandItem
|
||||
key={code}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
code,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{code}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='Status Codes'
|
||||
emptyLabel='All codes'
|
||||
selectedValues={selectedStatusCodes}
|
||||
onSelectedValuesChange={onStatusCodesChange}
|
||||
options={STATUS_CODE_OPTIONS}
|
||||
searchValue={statusSearch}
|
||||
onSearchValueChange={setStatusSearch}
|
||||
searchPlaceholder='Search or add status code...'
|
||||
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Common Codes'
|
||||
canAddCustom={(value) => /^\d+$/.test(value)}
|
||||
quickFilters={[
|
||||
{
|
||||
label: '4xx Errors',
|
||||
checked: STATUS_4XX_CODES.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
),
|
||||
onSelect: () => handleQuickStatusCode('4xx'),
|
||||
},
|
||||
{
|
||||
label: '5xx Errors',
|
||||
checked: STATUS_5XX_CODES.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
),
|
||||
onSelect: () => handleQuickStatusCode('5xx'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>HTTP Methods</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selectedMethods.length > 0 ? (
|
||||
selectedMethods.map((method) => (
|
||||
<FilterBadge
|
||||
key={method}
|
||||
value={method}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
val,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>All methods</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-64 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add method...'
|
||||
value={methodSearch}
|
||||
onValueChange={setMethodSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedMethods.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedMethods.map((method) => (
|
||||
<CommandItem
|
||||
key={`selected-${method}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
method,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{method}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{methodSearch &&
|
||||
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
|
||||
!selectedMethods.includes(methodSearch.toUpperCase()) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
methodSearch.toUpperCase(),
|
||||
onMethodsChange
|
||||
);
|
||||
setMethodSearch('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{methodSearch.toUpperCase()}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{METHOD_OPTIONS.filter(
|
||||
(method) => !selectedMethods.includes(method)
|
||||
).map((method) => (
|
||||
<CommandItem
|
||||
key={method}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
method,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{method}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='HTTP Methods'
|
||||
emptyLabel='All methods'
|
||||
selectedValues={selectedMethods}
|
||||
onSelectedValuesChange={onMethodsChange}
|
||||
options={METHOD_OPTIONS}
|
||||
searchValue={methodSearch}
|
||||
onSearchValueChange={setMethodSearch}
|
||||
searchPlaceholder='Search or add method...'
|
||||
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Methods'
|
||||
normalizeCustomValue={(value) => value.toUpperCase()}
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>Endpoints</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1 overflow-hidden'>
|
||||
{selectedEndpoints.length > 0 ? (
|
||||
selectedEndpoints.map((endpoint) => (
|
||||
<FilterBadge
|
||||
key={endpoint}
|
||||
value={endpoint}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
val,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>
|
||||
All endpoints
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-80 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add endpoint pattern...'
|
||||
value={endpointSearch}
|
||||
onValueChange={setEndpointSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedEndpoints.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedEndpoints.map((endpoint) => (
|
||||
<CommandItem
|
||||
key={`selected-${endpoint}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpoint,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{endpoint}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{endpointSearch &&
|
||||
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
|
||||
!selectedEndpoints.includes(endpointSearch) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpointSearch,
|
||||
onEndpointsChange
|
||||
);
|
||||
setEndpointSearch('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{endpointSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading='Common Endpoints'>
|
||||
{ENDPOINT_OPTIONS.filter(
|
||||
(endpoint) => !selectedEndpoints.includes(endpoint)
|
||||
).map((endpoint) => (
|
||||
<CommandItem
|
||||
key={endpoint}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpoint,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{endpoint}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='Endpoints'
|
||||
emptyLabel='All endpoints'
|
||||
selectedValues={selectedEndpoints}
|
||||
onSelectedValuesChange={onEndpointsChange}
|
||||
options={ENDPOINT_OPTIONS}
|
||||
searchValue={endpointSearch}
|
||||
onSearchValueChange={setEndpointSearch}
|
||||
searchPlaceholder='Search or add endpoint pattern...'
|
||||
popoverClassName='w-[min(20rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Common Endpoints'
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='request-id'>Request ID</Label>
|
||||
@@ -652,7 +346,7 @@ export function LogFilters({
|
||||
type='text'
|
||||
placeholder='Search by request ID'
|
||||
value={requestId}
|
||||
onChange={(e) => onRequestIdChange(e.target.value)}
|
||||
onChange={(event) => onRequestIdChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -668,14 +362,14 @@ export function LogFilters({
|
||||
type='text'
|
||||
placeholder='Search in message and name'
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchTextChange(e.target.value)}
|
||||
onChange={(event) => onSearchTextChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='limit'>Limit</Label>
|
||||
{isCustom ? (
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
id='limit'
|
||||
type='number'
|
||||
@@ -686,7 +380,7 @@ export function LogFilters({
|
||||
onKeyDown={handleCustomLimitKeyDown}
|
||||
onBlur={handleCustomLimitApply}
|
||||
autoFocus
|
||||
className='flex-1'
|
||||
className='flex-1 sm:flex-auto'
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -695,6 +389,7 @@ export function LogFilters({
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
|
||||
if (!isPreset) {
|
||||
onLimitChange(100);
|
||||
}
|
||||
@@ -712,12 +407,11 @@ export function LogFilters({
|
||||
<SelectValue placeholder='Select limit' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='25'>25</SelectItem>
|
||||
<SelectItem value='50'>50</SelectItem>
|
||||
<SelectItem value='100'>100</SelectItem>
|
||||
<SelectItem value='200'>200</SelectItem>
|
||||
<SelectItem value='500'>500</SelectItem>
|
||||
<SelectItem value='1000'>1000</SelectItem>
|
||||
{PRESET_LIMITS.map((preset) => (
|
||||
<SelectItem key={preset} value={preset}>
|
||||
{preset}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value='custom'>Custom...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -727,8 +421,7 @@ export function LogFilters({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label> </Label>
|
||||
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant='outline'
|
||||
|
||||
182
ui/app/logs/multi-select-command-filter.tsx
Normal file
182
ui/app/logs/multi-select-command-filter.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
interface QuickFilterOption {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface MultiSelectCommandFilterProps {
|
||||
label: string;
|
||||
emptyLabel: string;
|
||||
selectedValues: string[];
|
||||
onSelectedValuesChange: (values: string[]) => void;
|
||||
options: string[];
|
||||
searchValue: string;
|
||||
onSearchValueChange: (value: string) => void;
|
||||
searchPlaceholder: string;
|
||||
popoverClassName?: string;
|
||||
selectedGroupLabel?: string;
|
||||
customGroupLabel?: string;
|
||||
quickGroupLabel?: string;
|
||||
optionsGroupLabel?: string;
|
||||
quickFilters?: QuickFilterOption[];
|
||||
normalizeCustomValue?: (value: string) => string;
|
||||
canAddCustom?: (value: string) => boolean;
|
||||
}
|
||||
|
||||
function FilterBadge({ value }: { value: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='flex items-center gap-1 px-1 font-normal'
|
||||
>
|
||||
{value}
|
||||
<X className='h-3 w-3 opacity-70' aria-hidden='true' />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectCommandFilter({
|
||||
label,
|
||||
emptyLabel,
|
||||
selectedValues,
|
||||
onSelectedValuesChange,
|
||||
options,
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
searchPlaceholder,
|
||||
popoverClassName = 'w-64 p-0',
|
||||
selectedGroupLabel = 'Selected',
|
||||
customGroupLabel = 'Custom',
|
||||
quickGroupLabel = 'Quick Filters',
|
||||
optionsGroupLabel = 'Options',
|
||||
quickFilters = [],
|
||||
normalizeCustomValue,
|
||||
canAddCustom,
|
||||
}: MultiSelectCommandFilterProps) {
|
||||
const toggleSelection = (value: string) => {
|
||||
if (selectedValues.includes(value)) {
|
||||
onSelectedValuesChange(selectedValues.filter((item) => item !== value));
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectedValuesChange([...selectedValues, value]);
|
||||
};
|
||||
|
||||
const normalizedSearch = normalizeCustomValue
|
||||
? normalizeCustomValue(searchValue)
|
||||
: searchValue;
|
||||
|
||||
const canShowCustomAction =
|
||||
normalizedSearch.length > 0 &&
|
||||
!options.includes(normalizedSearch) &&
|
||||
!selectedValues.includes(normalizedSearch) &&
|
||||
(canAddCustom ? canAddCustom(normalizedSearch) : true);
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label>{label}</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1 overflow-hidden'>
|
||||
{selectedValues.length > 0 ? (
|
||||
selectedValues.map((value) => (
|
||||
<FilterBadge key={value} value={value} />
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>{emptyLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={popoverClassName} align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onValueChange={onSearchValueChange}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedValues.length > 0 && (
|
||||
<CommandGroup heading={selectedGroupLabel}>
|
||||
{selectedValues.map((value) => (
|
||||
<CommandItem
|
||||
key={`selected-${value}`}
|
||||
onSelect={() => toggleSelection(value)}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{canShowCustomAction && (
|
||||
<CommandGroup heading={customGroupLabel}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(normalizedSearch);
|
||||
onSearchValueChange('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{normalizedSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
{quickFilters.length > 0 && (
|
||||
<CommandGroup heading={quickGroupLabel}>
|
||||
{quickFilters.map((filter) => (
|
||||
<CommandItem key={filter.label} onSelect={filter.onSelect}>
|
||||
<Checkbox checked={filter.checked} className='mr-2' />
|
||||
{filter.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup heading={optionsGroupLabel}>
|
||||
{options
|
||||
.filter((option) => !selectedValues.includes(option))
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
onSelect={() => toggleSelection(option)}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,9 +12,18 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { FileText, RefreshCw } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { LogEntry, LogsResponse } from './types';
|
||||
import { LogFilters } from './log-filters';
|
||||
import { LogEntryCard } from './log-entry-card';
|
||||
@@ -135,126 +142,125 @@ export default function LogsPage() {
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
Boolean(requestId) ||
|
||||
Boolean(searchText) ||
|
||||
selectedStatusCodes.length > 0 ||
|
||||
selectedMethods.length > 0 ||
|
||||
selectedEndpoints.length > 0;
|
||||
|
||||
const activeFilterDescription = [
|
||||
selectedDate !== 'all' ? `date ${selectedDate}` : null,
|
||||
selectedLevel !== 'all' ? `level ${selectedLevel}` : null,
|
||||
requestId ? `request ID ${requestId}` : null,
|
||||
searchText ? `text "${searchText}"` : null,
|
||||
selectedStatusCodes.length > 0
|
||||
? `status ${selectedStatusCodes.join(', ')}`
|
||||
: null,
|
||||
selectedMethods.length > 0 ? `method ${selectedMethods.join(', ')}` : null,
|
||||
selectedEndpoints.length > 0
|
||||
? `endpoint ${selectedEndpoints.join(', ')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='overflow-x-hidden p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
|
||||
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
|
||||
System Logs
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
|
||||
View and filter application logs
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetchLogs()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='self-start'
|
||||
>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='System Logs'
|
||||
description='View and filter application logs.'
|
||||
actions={
|
||||
<Button onClick={() => refetchLogs()} variant='outline' size='sm'>
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<LogFilters
|
||||
selectedDate={selectedDate}
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
selectedStatusCodes={selectedStatusCodes}
|
||||
selectedMethods={selectedMethods}
|
||||
selectedEndpoints={selectedEndpoints}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onStatusCodesChange={setSelectedStatusCodes}
|
||||
onMethodsChange={setSelectedMethods}
|
||||
onEndpointsChange={setSelectedEndpoints}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
<LogFilters
|
||||
selectedDate={selectedDate}
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
selectedStatusCodes={selectedStatusCodes}
|
||||
selectedMethods={selectedMethods}
|
||||
selectedEndpoints={selectedEndpoints}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onStatusCodesChange={setSelectedStatusCodes}
|
||||
onMethodsChange={setSelectedMethods}
|
||||
onEndpointsChange={setSelectedEndpoints}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-lg sm:text-xl'>Log Entries</span>
|
||||
{logsData && (
|
||||
<Badge variant='secondary' className='text-xs sm:text-sm'>
|
||||
{logsData.logs.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{(selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
requestId ||
|
||||
searchText ||
|
||||
selectedStatusCodes.length > 0 ||
|
||||
selectedMethods.length > 0 ||
|
||||
selectedEndpoints.length > 0) && (
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
Showing logs
|
||||
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
||||
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
||||
{requestId && ` with request ID ${requestId}`}
|
||||
{searchText && ` matching "${searchText}"`}
|
||||
{selectedStatusCodes.length > 0 &&
|
||||
` with status ${selectedStatusCodes.join(', ')}`}
|
||||
{selectedMethods.length > 0 &&
|
||||
` with method ${selectedMethods.join(', ')}`}
|
||||
{selectedEndpoints.length > 0 &&
|
||||
` with endpoint ${selectedEndpoints.join(', ')}`}
|
||||
</CardDescription>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>Log Entries</CardTitle>
|
||||
{logsData && (
|
||||
<Badge variant='secondary'>
|
||||
{logsData.logs.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden p-3 sm:p-6'>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<RefreshCw className='h-6 w-6 animate-spin' />
|
||||
<span className='ml-2 text-sm sm:text-base'>
|
||||
Loading logs...
|
||||
</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Showing logs filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{isLoading ? (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`logs-loading-${index}`}
|
||||
className='h-16 w-full rounded-lg'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : logsData?.logs && logsData.logs.length > 0 ? (
|
||||
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
|
||||
<div className='space-y-2 pr-3'>
|
||||
{logsData.logs.map((entry, index) => (
|
||||
<LogEntryCard
|
||||
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
|
||||
entry={entry}
|
||||
onClick={handleLogClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : logsData?.logs && logsData.logs.length > 0 ? (
|
||||
<>
|
||||
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
|
||||
<div className='space-y-2 pr-3'>
|
||||
{logsData.logs.map((entry, index) => (
|
||||
<LogEntryCard
|
||||
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
|
||||
entry={entry}
|
||||
onClick={handleLogClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
|
||||
<p className='text-sm sm:text-base'>No log entries found</p>
|
||||
<p className='text-xs sm:text-sm'>
|
||||
Try adjusting your filters or check back later
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<Empty className='py-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<FileText className='h-4 w-4' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No log entries found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Try adjusting your filters or check back later.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LogDetailsDialog
|
||||
log={selectedLog}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<LogDetailsDialog
|
||||
log={selectedLog}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ModelSelector } from '@/components/ModelSelector';
|
||||
import { ModelTester } from '@/components/ModelTester';
|
||||
import { ApiEndpointTester } from '@/components/ApiEndpointTester';
|
||||
import { ModelSearchFilter } from '@/components/ModelSearchFilter';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertCircle, Users, Globe } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-models-with-providers'],
|
||||
queryFn: () => AdminService.getModelsWithProviders(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const { models = [], groups = [] } = modelsData || {};
|
||||
|
||||
const groupedModels = useMemo(() => {
|
||||
return groupAndSortModelsByProvider(models);
|
||||
}, [models]);
|
||||
|
||||
const groupDataMap = useMemo(() => {
|
||||
return new Map(groups.map((group) => [group.provider, group]));
|
||||
}, [groups]);
|
||||
|
||||
const providerInfo = useMemo(() => {
|
||||
const allProviders = new Set([
|
||||
...Object.keys(groupedModels),
|
||||
...groups.map((g) => g.provider),
|
||||
]);
|
||||
console.log(allProviders);
|
||||
|
||||
return Array.from(allProviders).map((provider) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
const groupData = groupDataMap.get(provider);
|
||||
const activeModels = providerModels.filter(
|
||||
(m) => m.isEnabled && !m.soft_deleted
|
||||
).length;
|
||||
const totalModels = providerModels.length;
|
||||
|
||||
return {
|
||||
provider,
|
||||
activeModels,
|
||||
totalModels,
|
||||
groupData,
|
||||
hasGroupUrl: !!groupData?.group_url,
|
||||
hasGroupApiKey: !!groupData?.group_api_key,
|
||||
};
|
||||
});
|
||||
}, [groupedModels, groupDataMap, groups]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>
|
||||
Model Management & API Testing
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue='manage' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-3'>
|
||||
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
|
||||
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
|
||||
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='manage' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Manage your AI models organized by provider groups. Configure
|
||||
API keys, and organize models by provider groups.
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[60px] w-full' />
|
||||
<Skeleton className='h-[400px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models. Please try refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Tabs defaultValue='all' className='w-full'>
|
||||
<div className='space-y-4'>
|
||||
{/* Provider Tabs Navigation */}
|
||||
<div className='overflow-x-auto rounded-lg border p-1'>
|
||||
<TabsList className='grid w-full max-w-full min-w-max auto-cols-fr grid-flow-col gap-1 sm:gap-2'>
|
||||
<TabsTrigger
|
||||
value='all'
|
||||
className='flex items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<Globe className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
<span className='hidden sm:inline'>All Models</span>
|
||||
<span className='sm:hidden'>All</span>
|
||||
<Badge variant='secondary' className='ml-1 text-xs'>
|
||||
{models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
{providerInfo.map(
|
||||
({ provider, activeModels, totalModels }) => (
|
||||
<TabsTrigger
|
||||
key={provider}
|
||||
value={provider}
|
||||
className='flex min-w-fit items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<Users className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
<span className='max-w-20 truncate sm:max-w-none'>
|
||||
{provider}
|
||||
</span>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='ml-1 text-xs'
|
||||
>
|
||||
{activeModels}/{totalModels}
|
||||
</Badge>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
)
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* All Models Tab */}
|
||||
<TabsContent value='all'>
|
||||
<div className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Overview of all models across all provider groups.
|
||||
</div>
|
||||
<ModelSearchFilter
|
||||
models={models}
|
||||
onFilteredModelsChange={setFilteredModels}
|
||||
/>
|
||||
<ModelSelector
|
||||
filteredModels={filteredModels}
|
||||
showDeleteAllButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{providerInfo.map(
|
||||
({ provider, totalModels, groupData }) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
|
||||
return (
|
||||
<TabsContent key={provider} value={provider}>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h3 className='flex items-center gap-2 text-lg font-semibold'>
|
||||
<Users className='h-5 w-5' />
|
||||
{provider}
|
||||
</h3>
|
||||
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
|
||||
{providerModels.filter(
|
||||
(m) => m.soft_deleted
|
||||
).length > 0 && (
|
||||
<span className='text-orange-600'>
|
||||
{
|
||||
providerModels.filter(
|
||||
(m) => m.soft_deleted
|
||||
).length
|
||||
}{' '}
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
{groupData?.group_url && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Globe className='h-3 w-3' />
|
||||
{groupData.group_url}
|
||||
</span>
|
||||
)}
|
||||
{totalModels === 0 && (
|
||||
<span className='text-muted-foreground'>
|
||||
No models configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{totalModels === 0 && (
|
||||
<Alert className='mb-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium'>
|
||||
No models found for this provider
|
||||
</p>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>
|
||||
Common issues:
|
||||
</p>
|
||||
<ul className='ml-2 list-inside list-disc space-y-1'>
|
||||
<li>
|
||||
<strong>API credentials:</strong>{' '}
|
||||
Check if the API key is correct
|
||||
and has the right permissions
|
||||
</li>
|
||||
<li>
|
||||
<strong>Base URL:</strong> Verify
|
||||
the base URL is correct for your
|
||||
provider
|
||||
</li>
|
||||
<li>
|
||||
<strong>Network access:</strong>{' '}
|
||||
Ensure the server can reach the
|
||||
provider's API endpoint
|
||||
</li>
|
||||
<li>
|
||||
<strong>Provider status:</strong>{' '}
|
||||
The upstream provider might be
|
||||
temporarily unavailable
|
||||
</li>
|
||||
</ul>
|
||||
{groupData?.group_url && (
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
Current endpoint:{' '}
|
||||
<code className='bg-muted rounded px-1'>
|
||||
{groupData.group_url}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<ModelSelector
|
||||
filterProvider={provider}
|
||||
groupData={groupData}
|
||||
showProviderActions={true}
|
||||
showDeleteAllButton={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-basic' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Test model credentials and connectivity with basic chat
|
||||
completion requests through the secure proxy (resolves CORS
|
||||
and Docker network issues). Models can be tested even without
|
||||
API keys configured (useful for free models or when
|
||||
authentication is handled elsewhere).
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[200px] w-full' />
|
||||
<Skeleton className='h-[100px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for testing. Please try refreshing
|
||||
the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ModelTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-api' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Comprehensive testing of all OpenAI API endpoints including
|
||||
chat completions, embeddings, image generation, audio
|
||||
synthesis, and model listing through the secure proxy
|
||||
(resolves CORS and Docker network issues). Models can be
|
||||
tested with or without API keys configured.
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[300px] w-full' />
|
||||
<Skeleton className='h-[200px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for API testing. Please try
|
||||
refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ApiEndpointTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
253
ui/app/models/page.tsx
Normal file
253
ui/app/models/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ModelSelector } from '@/components/model-selector';
|
||||
import { ModelTester } from '@/components/model-tester';
|
||||
import { ApiEndpointTester } from '@/components/api-endpoint-tester';
|
||||
import { ModelSearchFilter } from '@/components/model-search-filter';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [selectedProviderScope, setSelectedProviderScope] =
|
||||
useState<string>('all');
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-models-with-providers'],
|
||||
queryFn: () => AdminService.getModelsWithProviders(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const { models = [], groups = [] } = modelsData || {};
|
||||
|
||||
const groupedModels = useMemo(
|
||||
() => groupAndSortModelsByProvider(models),
|
||||
[models]
|
||||
);
|
||||
|
||||
const groupDataMap = useMemo(
|
||||
() => new Map(groups.map((group) => [group.provider, group])),
|
||||
[groups]
|
||||
);
|
||||
|
||||
const providerInfo = useMemo(() => {
|
||||
const allProviders = new Set([
|
||||
...Object.keys(groupedModels),
|
||||
...groups.map((group) => group.provider),
|
||||
]);
|
||||
|
||||
return Array.from(allProviders)
|
||||
.map((provider) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
const groupData = groupDataMap.get(provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
totalModels: providerModels.length,
|
||||
disabledModels: providerModels.filter((model) => model.soft_deleted)
|
||||
.length,
|
||||
groupData,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||
}, [groupDataMap, groupedModels, groups]);
|
||||
|
||||
const activeProviderScope = useMemo(() => {
|
||||
if (selectedProviderScope === 'all') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const providerExists = providerInfo.some(
|
||||
(provider) => provider.provider === selectedProviderScope
|
||||
);
|
||||
|
||||
return providerExists ? selectedProviderScope : 'all';
|
||||
}, [providerInfo, selectedProviderScope]);
|
||||
|
||||
const selectedProviderGroup =
|
||||
activeProviderScope === 'all'
|
||||
? undefined
|
||||
: groupDataMap.get(activeProviderScope);
|
||||
|
||||
const scopedModels = useMemo(() => {
|
||||
if (activeProviderScope === 'all') {
|
||||
return models;
|
||||
}
|
||||
|
||||
return models.filter((model) => model.provider === activeProviderScope);
|
||||
}, [activeProviderScope, models]);
|
||||
|
||||
return (
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<PageHeader
|
||||
title='Model Management'
|
||||
description='Manage provider model catalogs and validate endpoints from one place.'
|
||||
/>
|
||||
|
||||
<Tabs defaultValue='manage' className='w-full gap-3 sm:gap-4'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='w-full snap-x snap-mandatory justify-start gap-0.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
>
|
||||
<TabsTrigger
|
||||
value='manage'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
Manage Models
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='test-basic'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
Basic Testing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='test-api'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
API Endpoints
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='manage' className='mt-0'>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-16 w-full' />
|
||||
<Skeleton className='h-[420px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models. Please try refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='flex flex-col gap-2 sm:gap-2.5 md:flex-row md:items-center'>
|
||||
<Select
|
||||
value={activeProviderScope}
|
||||
onValueChange={(value) => {
|
||||
setSelectedProviderScope(value);
|
||||
setFilteredModels(undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full md:w-[220px]'>
|
||||
<SelectValue placeholder='Provider scope' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='start'>
|
||||
<SelectItem value='all'>
|
||||
All providers ({models.length})
|
||||
</SelectItem>
|
||||
{providerInfo.map(({ provider, totalModels }) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider} ({totalModels})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ModelSearchFilter
|
||||
models={scopedModels}
|
||||
onFilteredModelsChange={setFilteredModels}
|
||||
className='w-full min-w-0 flex-1'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
filterProvider={
|
||||
activeProviderScope === 'all'
|
||||
? undefined
|
||||
: activeProviderScope
|
||||
}
|
||||
groupData={selectedProviderGroup}
|
||||
filteredModels={filteredModels}
|
||||
showDeleteAllButton={activeProviderScope === 'all'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-basic' className='mt-0 space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-base font-semibold'>
|
||||
Basic Credential Testing
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Run chat-completion checks through the secure proxy to validate
|
||||
model credentials and endpoint connectivity.
|
||||
</p>
|
||||
</div>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[220px] w-full' />
|
||||
<Skeleton className='h-[120px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for testing. Please try refreshing the
|
||||
page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ModelTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-api' className='mt-0 space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-base font-semibold'>
|
||||
OpenAI Endpoint Testing
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Validate chat, embeddings, image, audio, and model-listing
|
||||
endpoints through the secure proxy.
|
||||
</p>
|
||||
</div>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[320px] w-full' />
|
||||
<Skeleton className='h-[220px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for API testing. Please try refreshing
|
||||
the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ApiEndpointTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
1283
ui/app/page.tsx
1283
ui/app/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,11 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
import { ProtectedRoute } from '@/lib/auth/ProtectedRoute';
|
||||
import { AuthProvider } from '@/lib/auth/auth-context';
|
||||
import { ProtectedRoute } from '@/lib/auth/protected-route';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
interface ProvidersProps {
|
||||
children: ReactNode;
|
||||
@@ -34,12 +35,14 @@ export function Providers({ children }: ProvidersProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
<Toaster position='top-right' />
|
||||
</ProtectedRoute>
|
||||
</AuthProvider>
|
||||
<TooltipProvider>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
<Toaster position='top-right' />
|
||||
</ProtectedRoute>
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,37 +4,30 @@ import * as React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
|
||||
import { AdminSettings } from '@/components/settings/admin-settings';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { Toaster } from 'sonner';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='flex items-center'>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>Settings</h1>
|
||||
</div>
|
||||
<Tabs defaultValue='admin' className='w-full'>
|
||||
<TabsList className='mb-4'>
|
||||
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='server'>
|
||||
<ServerConfigSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value='admin'>
|
||||
<AdminSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<Toaster />
|
||||
</SidebarProvider>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='Settings'
|
||||
description='Manage admin authentication, service metadata, and upstream forwarding.'
|
||||
/>
|
||||
<Tabs defaultValue='admin' className='w-full'>
|
||||
<TabsList variant='line' className='mb-4 w-full'>
|
||||
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
|
||||
<TabsTrigger value='server'>Server Config</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='server'>
|
||||
<ServerConfigSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value='admin'>
|
||||
<AdminSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
created_at: string;
|
||||
token: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
interface PaginatedTransactionsResponse {
|
||||
transactions: Transaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
const TransactionService = {
|
||||
getAllTransactions: async (): Promise<Transaction[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<Transaction[]>('/api/transactions');
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transactions:', error);
|
||||
throw new Error('Failed to fetch transactions');
|
||||
}
|
||||
},
|
||||
|
||||
getPaginatedTransactions: async (
|
||||
page: number,
|
||||
perPage: number
|
||||
): Promise<PaginatedTransactionsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<PaginatedTransactionsResponse>(
|
||||
`/api/transactions/paginated/${page}/${perPage}`
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch paginated transactions:', error);
|
||||
throw new Error('Failed to fetch paginated transactions');
|
||||
}
|
||||
},
|
||||
|
||||
getRecentTransactions: async (limit: number): Promise<Transaction[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<Transaction[]>(
|
||||
`/api/transactions/recent/${limit}`
|
||||
);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent transactions:', error);
|
||||
throw new Error('Failed to fetch recent transactions');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const perPage = 20;
|
||||
|
||||
// Fetch paginated transactions data
|
||||
const {
|
||||
data: paginationData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['transactions', currentPage, perPage],
|
||||
queryFn: () =>
|
||||
TransactionService.getPaginatedTransactions(currentPage, perPage),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
const transactions = paginationData?.transactions || [];
|
||||
const totalPages = paginationData?.total_pages || 0;
|
||||
const total = paginationData?.total || 0;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatAmount = (amount: string) => {
|
||||
return `${parseInt(amount).toLocaleString()} msats`;
|
||||
};
|
||||
|
||||
const truncateToken = (token: string) => {
|
||||
if (token.length <= 20) return token;
|
||||
return `${token.slice(0, 10)}...${token.slice(-10)}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Token copied to clipboard!');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
toast.error('Failed to copy token');
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>
|
||||
Transaction History
|
||||
</h1>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
View all Cashu token transactions processed by the system
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='space-y-4'>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className='h-[120px] w-full' />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load transactions.{' '}
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: 'Please check if the server is running and try refreshing the page.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className='py-8 text-center'>
|
||||
<p className='text-muted-foreground'>
|
||||
No transactions found.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Showing {(currentPage - 1) * perPage + 1} to{' '}
|
||||
{Math.min(currentPage * perPage, total)} of {total}{' '}
|
||||
transactions
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-[100px]'>ID</TableHead>
|
||||
<TableHead>Date & Time</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead className='w-[400px]'>
|
||||
Cashu Token
|
||||
</TableHead>
|
||||
<TableHead className='w-[60px]'>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction) => (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{transaction.id.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
{formatDate(transaction.created_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='secondary'>
|
||||
{formatAmount(transaction.amount)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className='max-w-[300px] cursor-pointer truncate rounded px-1 py-0.5 font-mono text-xs hover:bg-gray-100'>
|
||||
{truncateToken(transaction.token)}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='max-w-md break-all'>
|
||||
<p className='font-mono text-xs'>
|
||||
{transaction.token}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
copyToClipboard(transaction.token)
|
||||
}
|
||||
className='h-8 w-8 p-0'
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={goToPrevious}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className='mr-1 h-4 w-4' />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
<div className='flex items-center space-x-1'>
|
||||
{Array.from(
|
||||
{ length: Math.min(5, totalPages) },
|
||||
(_, i) => {
|
||||
const pageNumber =
|
||||
currentPage <= 3
|
||||
? i + 1
|
||||
: currentPage >= totalPages - 2
|
||||
? totalPages - 4 + i
|
||||
: currentPage - 2 + i;
|
||||
|
||||
if (pageNumber < 1 || pageNumber > totalPages)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant={
|
||||
currentPage === pageNumber
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
size='sm'
|
||||
onClick={() => goToPage(pageNumber)}
|
||||
className='h-9 w-9 p-0'
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={goToNext}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className='ml-1 h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,30 +3,34 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ShieldAlertIcon } from 'lucide-react';
|
||||
import { AuthPageShell } from '@/components/auth-page-shell';
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className='bg-background flex min-h-screen flex-col items-center justify-center p-4'>
|
||||
<div className='flex max-w-md flex-col items-center space-y-6 text-center'>
|
||||
<ShieldAlertIcon className='text-destructive h-24 w-24' />
|
||||
|
||||
<h1 className='text-4xl font-bold'>Access Denied</h1>
|
||||
|
||||
<p className='text-muted-foreground text-lg'>
|
||||
You don't have permission to access this page. Please contact
|
||||
your administrator if you believe this is an error.
|
||||
<AuthPageShell
|
||||
title='Access Denied'
|
||||
description="You don't have permission to access this page."
|
||||
>
|
||||
<div className='flex flex-col items-center gap-6'>
|
||||
<ShieldAlertIcon className='text-destructive h-20 w-20' />
|
||||
<p className='text-muted-foreground text-center text-sm'>
|
||||
Contact your administrator if you believe this is an error.
|
||||
</p>
|
||||
|
||||
<div className='flex gap-4'>
|
||||
<Button onClick={() => router.push('/')}>Go to Dashboard</Button>
|
||||
|
||||
<Button variant='outline' onClick={() => router.back()}>
|
||||
<div className='flex w-full flex-col gap-3 sm:w-auto sm:flex-row'>
|
||||
<Button onClick={() => router.push('/')} className='w-full sm:w-auto'>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => router.back()}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "stone",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -17,5 +19,5 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -45,9 +46,10 @@ export function AddModelForm({
|
||||
isOpen,
|
||||
}: AddModelFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
type ManualModelInput = z.input<typeof ManualModelSchema>;
|
||||
|
||||
const form = useForm<ManualModel>({
|
||||
resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<ManualModelInput, unknown, ManualModel>({
|
||||
resolver: zodResolver(ManualModelSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
full_name: '',
|
||||
699
ui/components/api-endpoint-form.tsx
Normal file
699
ui/components/api-endpoint-form.tsx
Normal file
@@ -0,0 +1,699 @@
|
||||
import type { ChangeEvent, Dispatch, SetStateAction } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Image as ImageIcon, List, Mic, MicOff, Volume2 } from 'lucide-react';
|
||||
import type { EndpointType } from '@/components/api-endpoint-types';
|
||||
|
||||
interface ApiEndpointFormProps {
|
||||
selectedEndpoint: EndpointType;
|
||||
maxTokens: number;
|
||||
setMaxTokens: Dispatch<SetStateAction<number>>;
|
||||
temperature: number;
|
||||
setTemperature: Dispatch<SetStateAction<number>>;
|
||||
systemMessage: string;
|
||||
setSystemMessage: Dispatch<SetStateAction<string>>;
|
||||
userMessage: string;
|
||||
setUserMessage: Dispatch<SetStateAction<string>>;
|
||||
|
||||
visionMaxTokens: number;
|
||||
setVisionMaxTokens: Dispatch<SetStateAction<number>>;
|
||||
visionTemperature: number;
|
||||
setVisionTemperature: Dispatch<SetStateAction<number>>;
|
||||
visionSystemMessage: string;
|
||||
setVisionSystemMessage: Dispatch<SetStateAction<string>>;
|
||||
visionUserMessage: string;
|
||||
setVisionUserMessage: Dispatch<SetStateAction<string>>;
|
||||
imageDetail: 'low' | 'high' | 'auto';
|
||||
setImageDetail: Dispatch<SetStateAction<'low' | 'high' | 'auto'>>;
|
||||
selectedImage: File | null;
|
||||
imagePreviewUrl: string | null;
|
||||
onImageUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onRemoveImage: () => void;
|
||||
|
||||
embeddingInput: string;
|
||||
setEmbeddingInput: Dispatch<SetStateAction<string>>;
|
||||
encodingFormat: 'float' | 'base64';
|
||||
setEncodingFormat: Dispatch<SetStateAction<'float' | 'base64'>>;
|
||||
|
||||
imageCount: number;
|
||||
setImageCount: Dispatch<SetStateAction<number>>;
|
||||
imageSize: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
|
||||
setImageSize: Dispatch<
|
||||
SetStateAction<
|
||||
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
|
||||
>
|
||||
>;
|
||||
imageQuality: 'standard' | 'hd';
|
||||
setImageQuality: Dispatch<SetStateAction<'standard' | 'hd'>>;
|
||||
imageStyle: 'vivid' | 'natural';
|
||||
setImageStyle: Dispatch<SetStateAction<'vivid' | 'natural'>>;
|
||||
imagePrompt: string;
|
||||
setImagePrompt: Dispatch<SetStateAction<string>>;
|
||||
|
||||
speechVoice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
|
||||
setSpeechVoice: Dispatch<
|
||||
SetStateAction<'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'>
|
||||
>;
|
||||
speechFormat: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
|
||||
setSpeechFormat: Dispatch<
|
||||
SetStateAction<'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'>
|
||||
>;
|
||||
speechSpeed: number;
|
||||
setSpeechSpeed: Dispatch<SetStateAction<number>>;
|
||||
speechInput: string;
|
||||
setSpeechInput: Dispatch<SetStateAction<string>>;
|
||||
|
||||
audioTranscriptionPrompt: string;
|
||||
setAudioTranscriptionPrompt: Dispatch<SetStateAction<string>>;
|
||||
audioTemperature: number;
|
||||
setAudioTemperature: Dispatch<SetStateAction<number>>;
|
||||
audioLanguage: string;
|
||||
setAudioLanguage: Dispatch<SetStateAction<string>>;
|
||||
audioResponseFormat: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
|
||||
setAudioResponseFormat: Dispatch<
|
||||
SetStateAction<'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'>
|
||||
>;
|
||||
isRecording: boolean;
|
||||
onStartRecording: () => void;
|
||||
onStopRecording: () => void;
|
||||
onAudioUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
recordedAudio: File | null;
|
||||
recordingUrl: string | null;
|
||||
onRemoveAudio: () => void;
|
||||
}
|
||||
|
||||
export function ApiEndpointForm({
|
||||
selectedEndpoint,
|
||||
maxTokens,
|
||||
setMaxTokens,
|
||||
temperature,
|
||||
setTemperature,
|
||||
systemMessage,
|
||||
setSystemMessage,
|
||||
userMessage,
|
||||
setUserMessage,
|
||||
visionMaxTokens,
|
||||
setVisionMaxTokens,
|
||||
visionTemperature,
|
||||
setVisionTemperature,
|
||||
visionSystemMessage,
|
||||
setVisionSystemMessage,
|
||||
visionUserMessage,
|
||||
setVisionUserMessage,
|
||||
imageDetail,
|
||||
setImageDetail,
|
||||
selectedImage,
|
||||
imagePreviewUrl,
|
||||
onImageUpload,
|
||||
onRemoveImage,
|
||||
embeddingInput,
|
||||
setEmbeddingInput,
|
||||
encodingFormat,
|
||||
setEncodingFormat,
|
||||
imageCount,
|
||||
setImageCount,
|
||||
imageSize,
|
||||
setImageSize,
|
||||
imageQuality,
|
||||
setImageQuality,
|
||||
imageStyle,
|
||||
setImageStyle,
|
||||
imagePrompt,
|
||||
setImagePrompt,
|
||||
speechVoice,
|
||||
setSpeechVoice,
|
||||
speechFormat,
|
||||
setSpeechFormat,
|
||||
speechSpeed,
|
||||
setSpeechSpeed,
|
||||
speechInput,
|
||||
setSpeechInput,
|
||||
audioTranscriptionPrompt,
|
||||
setAudioTranscriptionPrompt,
|
||||
audioTemperature,
|
||||
setAudioTemperature,
|
||||
audioLanguage,
|
||||
setAudioLanguage,
|
||||
audioResponseFormat,
|
||||
setAudioResponseFormat,
|
||||
isRecording,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onAudioUpload,
|
||||
recordedAudio,
|
||||
recordingUrl,
|
||||
onRemoveAudio,
|
||||
}: ApiEndpointFormProps) {
|
||||
switch (selectedEndpoint) {
|
||||
case 'chat-completions':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='max-tokens'>Max Tokens</Label>
|
||||
<Input
|
||||
id='max-tokens'
|
||||
type='number'
|
||||
min={1}
|
||||
max={4000}
|
||||
value={maxTokens}
|
||||
onChange={(event) =>
|
||||
setMaxTokens(parseInt(event.target.value) || 150)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='temperature'>Temperature</Label>
|
||||
<Input
|
||||
id='temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(event) =>
|
||||
setTemperature(parseFloat(event.target.value) || 0.7)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='system-message'>System Message (Optional)</Label>
|
||||
<Textarea
|
||||
id='system-message'
|
||||
placeholder='Enter system message...'
|
||||
value={systemMessage}
|
||||
onChange={(event) => setSystemMessage(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='user-message'>Test Message</Label>
|
||||
<Textarea
|
||||
id='user-message'
|
||||
placeholder='Enter your test message...'
|
||||
value={userMessage}
|
||||
onChange={(event) => setUserMessage(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'vision-chat':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-max-tokens'>Max Tokens</Label>
|
||||
<Input
|
||||
id='vision-max-tokens'
|
||||
type='number'
|
||||
min={1}
|
||||
max={4000}
|
||||
value={visionMaxTokens}
|
||||
onChange={(event) =>
|
||||
setVisionMaxTokens(parseInt(event.target.value) || 300)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-temperature'>Temperature</Label>
|
||||
<Input
|
||||
id='vision-temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={visionTemperature}
|
||||
onChange={(event) =>
|
||||
setVisionTemperature(parseFloat(event.target.value) || 0.7)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-system-message'>
|
||||
System Message (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id='vision-system-message'
|
||||
placeholder='Enter system message...'
|
||||
value={visionSystemMessage}
|
||||
onChange={(event) => setVisionSystemMessage(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-user-message'>Test Message</Label>
|
||||
<Textarea
|
||||
id='vision-user-message'
|
||||
placeholder='Enter your test message...'
|
||||
value={visionUserMessage}
|
||||
onChange={(event) => setVisionUserMessage(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-detail'>Image Detail</Label>
|
||||
<Select
|
||||
value={imageDetail}
|
||||
onValueChange={(value: 'low' | 'high' | 'auto') =>
|
||||
setImageDetail(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='low'>Low</SelectItem>
|
||||
<SelectItem value='high'>High</SelectItem>
|
||||
<SelectItem value='auto'>Auto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-upload'>Upload Image (Optional)</Label>
|
||||
<Input
|
||||
type='file'
|
||||
accept='image/*'
|
||||
onChange={onImageUpload}
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
{selectedImage && (
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
<ImageIcon className='text-muted-foreground h-5 w-5' />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Selected image: {selectedImage.name}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onRemoveImage}
|
||||
className='ml-0 sm:ml-auto'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{imagePreviewUrl && (
|
||||
<div className='relative mt-2 aspect-square w-32 overflow-hidden rounded-md border'>
|
||||
<Image
|
||||
src={imagePreviewUrl}
|
||||
alt='Image preview'
|
||||
fill
|
||||
className='object-cover'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'embeddings':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='encoding-format'>Encoding Format</Label>
|
||||
<Select
|
||||
value={encodingFormat}
|
||||
onValueChange={(value: 'float' | 'base64') =>
|
||||
setEncodingFormat(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='float'>Float</SelectItem>
|
||||
<SelectItem value='base64'>Base64</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='embedding-input'>Text Input</Label>
|
||||
<Textarea
|
||||
id='embedding-input'
|
||||
placeholder='Enter text to generate embeddings for...'
|
||||
value={embeddingInput}
|
||||
onChange={(event) => setEmbeddingInput(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'images':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-count'>Number of Images</Label>
|
||||
<Input
|
||||
id='image-count'
|
||||
type='number'
|
||||
min={1}
|
||||
max={10}
|
||||
value={imageCount}
|
||||
onChange={(event) =>
|
||||
setImageCount(parseInt(event.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-size'>Size</Label>
|
||||
<Select
|
||||
value={imageSize}
|
||||
onValueChange={(
|
||||
value:
|
||||
| '256x256'
|
||||
| '512x512'
|
||||
| '1024x1024'
|
||||
| '1792x1024'
|
||||
| '1024x1792'
|
||||
) => setImageSize(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='256x256'>256x256</SelectItem>
|
||||
<SelectItem value='512x512'>512x512</SelectItem>
|
||||
<SelectItem value='1024x1024'>1024x1024</SelectItem>
|
||||
<SelectItem value='1792x1024'>1792x1024</SelectItem>
|
||||
<SelectItem value='1024x1792'>1024x1792</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-quality'>Quality</Label>
|
||||
<Select
|
||||
value={imageQuality}
|
||||
onValueChange={(value: 'standard' | 'hd') =>
|
||||
setImageQuality(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='standard'>Standard</SelectItem>
|
||||
<SelectItem value='hd'>HD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-style'>Style</Label>
|
||||
<Select
|
||||
value={imageStyle}
|
||||
onValueChange={(value: 'vivid' | 'natural') =>
|
||||
setImageStyle(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='vivid'>Vivid</SelectItem>
|
||||
<SelectItem value='natural'>Natural</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-prompt'>Image Prompt</Label>
|
||||
<Textarea
|
||||
id='image-prompt'
|
||||
placeholder='Describe the image you want to generate...'
|
||||
value={imagePrompt}
|
||||
onChange={(event) => setImagePrompt(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio-speech':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-voice'>Voice</Label>
|
||||
<Select
|
||||
value={speechVoice}
|
||||
onValueChange={(
|
||||
value:
|
||||
| 'alloy'
|
||||
| 'echo'
|
||||
| 'fable'
|
||||
| 'onyx'
|
||||
| 'nova'
|
||||
| 'shimmer'
|
||||
) => setSpeechVoice(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='alloy'>Alloy</SelectItem>
|
||||
<SelectItem value='echo'>Echo</SelectItem>
|
||||
<SelectItem value='fable'>Fable</SelectItem>
|
||||
<SelectItem value='onyx'>Onyx</SelectItem>
|
||||
<SelectItem value='nova'>Nova</SelectItem>
|
||||
<SelectItem value='shimmer'>Shimmer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-format'>Response Format</Label>
|
||||
<Select
|
||||
value={speechFormat}
|
||||
onValueChange={(
|
||||
value: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
|
||||
) => setSpeechFormat(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='mp3'>MP3</SelectItem>
|
||||
<SelectItem value='opus'>Opus</SelectItem>
|
||||
<SelectItem value='aac'>AAC</SelectItem>
|
||||
<SelectItem value='flac'>FLAC</SelectItem>
|
||||
<SelectItem value='wav'>WAV</SelectItem>
|
||||
<SelectItem value='pcm'>PCM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-speed'>Speed</Label>
|
||||
<Input
|
||||
id='speech-speed'
|
||||
type='number'
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.25}
|
||||
value={speechSpeed}
|
||||
onChange={(event) =>
|
||||
setSpeechSpeed(parseFloat(event.target.value) || 1.0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-input'>Text to Synthesize</Label>
|
||||
<Textarea
|
||||
id='speech-input'
|
||||
placeholder='Enter text to convert to speech...'
|
||||
value={speechInput}
|
||||
onChange={(event) => setSpeechInput(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio-transcription':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-prompt'>
|
||||
Prompt (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id='audio-transcription-prompt'
|
||||
placeholder='Enter a prompt for the transcription...'
|
||||
value={audioTranscriptionPrompt}
|
||||
onChange={(event) =>
|
||||
setAudioTranscriptionPrompt(event.target.value)
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-temperature'>
|
||||
Temperature
|
||||
</Label>
|
||||
<Input
|
||||
id='audio-transcription-temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={audioTemperature}
|
||||
onChange={(event) =>
|
||||
setAudioTemperature(parseFloat(event.target.value) || 0.0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-language'>
|
||||
Language (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id='audio-transcription-language'
|
||||
placeholder='e.g., en-US, fr-FR'
|
||||
value={audioLanguage}
|
||||
onChange={(event) => setAudioLanguage(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-response-format'>
|
||||
Response Format
|
||||
</Label>
|
||||
<Select
|
||||
value={audioResponseFormat}
|
||||
onValueChange={(
|
||||
value: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
|
||||
) => setAudioResponseFormat(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='json'>JSON</SelectItem>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
<SelectItem value='srt'>SRT</SelectItem>
|
||||
<SelectItem value='verbose_json'>Verbose JSON</SelectItem>
|
||||
<SelectItem value='vtt'>VTT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Voice Recording</Label>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant={isRecording ? 'destructive' : 'default'}
|
||||
size='sm'
|
||||
onClick={isRecording ? onStopRecording : onStartRecording}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<MicOff className='h-4 w-4' />
|
||||
Stop Recording
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className='h-4 w-4' />
|
||||
Start Recording
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isRecording && (
|
||||
<Badge variant='destructive' className='gap-1.5'>
|
||||
<span className='h-2 w-2 animate-pulse rounded-full bg-current' />
|
||||
Recording...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-upload'>
|
||||
Or Upload Audio File
|
||||
</Label>
|
||||
<Input
|
||||
type='file'
|
||||
accept='audio/*'
|
||||
onChange={onAudioUpload}
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
{recordedAudio && (
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
<Volume2 className='text-muted-foreground h-5 w-5' />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Selected audio: {recordedAudio.name}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onRemoveAudio}
|
||||
className='ml-0 sm:ml-auto'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{recordingUrl && (
|
||||
<div className='bg-muted mt-2 rounded-md p-4'>
|
||||
<audio controls src={recordingUrl} className='w-full'>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'models':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Alert>
|
||||
<List className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
This endpoint lists all available models from the provider. No
|
||||
additional parameters are required.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
306
ui/components/api-endpoint-response.tsx
Normal file
306
ui/components/api-endpoint-response.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import Image from 'next/image';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: {
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
finish_reason: string;
|
||||
}[];
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmbeddingResponse {
|
||||
object: string;
|
||||
data: {
|
||||
object: string;
|
||||
index: number;
|
||||
embedding: number[];
|
||||
}[];
|
||||
model: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
created: number;
|
||||
data: {
|
||||
url: string;
|
||||
revised_prompt?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AudioResponse {
|
||||
type: 'audio';
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionResponse {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ModelsListResponse {
|
||||
object: string;
|
||||
data: {
|
||||
id: string;
|
||||
object?: string;
|
||||
created?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type ApiResponse =
|
||||
| ChatCompletionResponse
|
||||
| EmbeddingResponse
|
||||
| ImageGenerationResponse
|
||||
| AudioResponse
|
||||
| AudioTranscriptionResponse
|
||||
| ModelsListResponse;
|
||||
|
||||
const isAudioResponse = (response: ApiResponse): response is AudioResponse => {
|
||||
return 'type' in response && response.type === 'audio';
|
||||
};
|
||||
|
||||
const isChatCompletionResponse = (
|
||||
response: ApiResponse
|
||||
): response is ChatCompletionResponse => {
|
||||
return 'choices' in response;
|
||||
};
|
||||
|
||||
const isEmbeddingResponse = (
|
||||
response: ApiResponse
|
||||
): response is EmbeddingResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'embedding' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isImageGenerationResponse = (
|
||||
response: ApiResponse
|
||||
): response is ImageGenerationResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'url' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isModelsListResponse = (
|
||||
response: ApiResponse
|
||||
): response is ModelsListResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'id' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isAudioTranscriptionResponse = (
|
||||
response: ApiResponse
|
||||
): response is AudioTranscriptionResponse => {
|
||||
return 'text' in response;
|
||||
};
|
||||
|
||||
export function ApiEndpointResponse({
|
||||
response,
|
||||
}: {
|
||||
response: ApiResponse | null;
|
||||
}) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAudioResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Generated Audio</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<audio controls src={response.url} className='w-full'>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
Audio file size: {(response.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAudioTranscriptionResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Transcription Result</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-sm whitespace-pre-wrap'>
|
||||
{response.text || 'No transcription result available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isChatCompletionResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Model Response</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-sm whitespace-pre-wrap'>
|
||||
{response.choices?.[0]?.message?.content ||
|
||||
'No content in response'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{response.usage && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Usage Statistics</Label>
|
||||
<div className='grid grid-cols-1 gap-2 text-sm sm:grid-cols-3 sm:gap-4'>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.prompt_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Prompt Tokens</div>
|
||||
</div>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.completion_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Completion Tokens</div>
|
||||
</div>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.total_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Total Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmbeddingResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Embedding Vector</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-muted-foreground mb-2 text-sm'>
|
||||
Generated {response.data?.[0]?.embedding?.length || 0} dimensional
|
||||
embedding vector
|
||||
</p>
|
||||
<details className='group'>
|
||||
<summary className='hover:text-foreground cursor-pointer text-sm'>
|
||||
Show first 10 values
|
||||
</summary>
|
||||
<pre className='mt-2 text-xs'>
|
||||
{JSON.stringify(
|
||||
response.data?.[0]?.embedding?.slice(0, 10),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
...
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{response.usage && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Usage Statistics</Label>
|
||||
<div className='bg-muted rounded p-2 text-center text-sm'>
|
||||
<div className='font-semibold'>{response.usage.total_tokens}</div>
|
||||
<div className='text-muted-foreground'>Total Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImageGenerationResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Generated Images</Label>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
{response.data?.map((image, index: number) => (
|
||||
<div key={index} className='space-y-2'>
|
||||
<div className='relative aspect-square w-full'>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={`Generated image ${index + 1}`}
|
||||
fill
|
||||
className='rounded-md border object-cover'
|
||||
unoptimized={true}
|
||||
/>
|
||||
</div>
|
||||
{image.revised_prompt && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Revised prompt: {image.revised_prompt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isModelsListResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Available Models</Label>
|
||||
<div className='max-h-60 overflow-auto'>
|
||||
<div className='grid gap-2'>
|
||||
{response.data?.map((model) => (
|
||||
<div key={model.id} className='bg-muted rounded-md p-3'>
|
||||
<div className='font-medium'>{model.id}</div>
|
||||
{model.object && (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Type: {model.object}
|
||||
</div>
|
||||
)}
|
||||
{model.created && (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Created:{' '}
|
||||
{new Date(model.created * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
726
ui/components/api-endpoint-tester.tsx
Normal file
726
ui/components/api-endpoint-tester.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { type Model } from '@/lib/api/schemas/models';
|
||||
import { ModelService } from '@/lib/api/services/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Loader2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info,
|
||||
Key,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ApiEndpointResponse,
|
||||
type ApiResponse,
|
||||
} from '@/components/api-endpoint-response';
|
||||
import { ApiEndpointForm } from '@/components/api-endpoint-form';
|
||||
import {
|
||||
API_ENDPOINTS,
|
||||
DEFAULT_REQUESTS,
|
||||
type EndpointType,
|
||||
type ChatCompletionRequest,
|
||||
type EmbeddingRequest,
|
||||
type ImageGenerationRequest,
|
||||
type AudioSpeechRequest,
|
||||
type AudioTranscriptionRequest,
|
||||
type EndpointRequestData,
|
||||
} from '@/components/api-endpoint-types';
|
||||
|
||||
interface ApiEndpointTesterProps {
|
||||
models: Model[];
|
||||
}
|
||||
|
||||
export function ApiEndpointTester({ models }: ApiEndpointTesterProps) {
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
||||
const [selectedEndpoint, setSelectedEndpoint] =
|
||||
useState<EndpointType>('chat-completions');
|
||||
const [response, setResponse] = useState<ApiResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Chat Completions state
|
||||
const [systemMessage, setSystemMessage] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].systemMessage
|
||||
);
|
||||
const [userMessage, setUserMessage] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].userMessage
|
||||
);
|
||||
const [maxTokens, setMaxTokens] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].maxTokens
|
||||
);
|
||||
const [temperature, setTemperature] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].temperature
|
||||
);
|
||||
|
||||
// Vision Chat state
|
||||
const [visionSystemMessage, setVisionSystemMessage] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].systemMessage
|
||||
);
|
||||
const [visionUserMessage, setVisionUserMessage] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].userMessage
|
||||
);
|
||||
const [visionMaxTokens, setVisionMaxTokens] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].maxTokens
|
||||
);
|
||||
const [visionTemperature, setVisionTemperature] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].temperature
|
||||
);
|
||||
const [imageDetail, setImageDetail] = useState<'low' | 'high' | 'auto'>(
|
||||
DEFAULT_REQUESTS['vision-chat'].imageDetail
|
||||
);
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// Voice Recording state
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordedAudio, setRecordedAudio] = useState<File | null>(null);
|
||||
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
|
||||
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(
|
||||
null
|
||||
);
|
||||
const [audioTranscriptionPrompt, setAudioTranscriptionPrompt] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].prompt
|
||||
);
|
||||
const [audioResponseFormat, setAudioResponseFormat] = useState<
|
||||
'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
|
||||
>(DEFAULT_REQUESTS['audio-transcription'].response_format);
|
||||
const [audioTemperature, setAudioTemperature] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].temperature
|
||||
);
|
||||
const [audioLanguage, setAudioLanguage] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].language
|
||||
);
|
||||
|
||||
// Embeddings state
|
||||
const [embeddingInput, setEmbeddingInput] = useState(
|
||||
DEFAULT_REQUESTS.embeddings.input
|
||||
);
|
||||
const [encodingFormat, setEncodingFormat] = useState<'float' | 'base64'>(
|
||||
DEFAULT_REQUESTS.embeddings.encoding_format
|
||||
);
|
||||
|
||||
// Image Generation state
|
||||
const [imagePrompt, setImagePrompt] = useState(
|
||||
DEFAULT_REQUESTS.images.prompt
|
||||
);
|
||||
const [imageCount, setImageCount] = useState(DEFAULT_REQUESTS.images.n);
|
||||
const [imageSize, setImageSize] = useState<
|
||||
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
|
||||
>(DEFAULT_REQUESTS.images.size);
|
||||
const [imageQuality, setImageQuality] = useState<'standard' | 'hd'>(
|
||||
DEFAULT_REQUESTS.images.quality
|
||||
);
|
||||
const [imageStyle, setImageStyle] = useState<'vivid' | 'natural'>(
|
||||
DEFAULT_REQUESTS.images.style
|
||||
);
|
||||
|
||||
// Audio Speech state
|
||||
const [speechInput, setSpeechInput] = useState(
|
||||
DEFAULT_REQUESTS['audio-speech'].input
|
||||
);
|
||||
const [speechVoice, setSpeechVoice] = useState<
|
||||
'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'
|
||||
>(DEFAULT_REQUESTS['audio-speech'].voice);
|
||||
const [speechFormat, setSpeechFormat] = useState<
|
||||
'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
|
||||
>(DEFAULT_REQUESTS['audio-speech'].response_format);
|
||||
const [speechSpeed, setSpeechSpeed] = useState(
|
||||
DEFAULT_REQUESTS['audio-speech'].speed
|
||||
);
|
||||
|
||||
// Fetch model groups for API key resolution
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['model-groups'],
|
||||
queryFn: () => ModelService.getModelGroups(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const selectedModel = models.find((model) => model.id === selectedModelId);
|
||||
|
||||
// Image upload handler
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
setSelectedImage(file);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setImagePreviewUrl(previewUrl);
|
||||
} else {
|
||||
toast.error('Please select a valid image file');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Audio file upload handler
|
||||
const handleAudioUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type.startsWith('audio/')) {
|
||||
setRecordedAudio(file);
|
||||
const audioUrl = URL.createObjectURL(file);
|
||||
setRecordingUrl(audioUrl);
|
||||
} else {
|
||||
toast.error('Please select a valid audio file');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Voice recording functions
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: BlobPart[] = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' });
|
||||
const file = new File([blob], 'recording.webm', { type: 'audio/webm' });
|
||||
setRecordedAudio(file);
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
setRecordingUrl(audioUrl);
|
||||
|
||||
// Stop all tracks to release microphone
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setMediaRecorder(recorder);
|
||||
setIsRecording(true);
|
||||
toast.success('Recording started');
|
||||
} catch {
|
||||
toast.error(
|
||||
'Failed to start recording. Please check microphone permissions.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder) {
|
||||
mediaRecorder.stop();
|
||||
setMediaRecorder(null);
|
||||
setIsRecording(false);
|
||||
toast.success('Recording stopped');
|
||||
}
|
||||
};
|
||||
|
||||
// Convert file to base64 for vision API
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Get effective API key and endpoint URL for the selected model
|
||||
const getModelCredentials = (model: Model) => {
|
||||
const group = groups.find((g) => g.provider === model.provider);
|
||||
|
||||
// Determine API key (individual takes precedence over group)
|
||||
const apiKey = model.api_key || group?.group_api_key;
|
||||
|
||||
// Determine base endpoint URL
|
||||
let baseUrl = model.url;
|
||||
|
||||
// If model URL is relative and group has a base URL, combine them
|
||||
if (model.url.startsWith('/') && group?.group_url) {
|
||||
baseUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`;
|
||||
}
|
||||
|
||||
// Remove any existing endpoint path to get base URL
|
||||
baseUrl = baseUrl.replace(/\/v1\/.*$/, '').replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
group,
|
||||
};
|
||||
};
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string, endpointPath: string) => {
|
||||
return `${baseUrl}${endpointPath}`;
|
||||
};
|
||||
|
||||
const buildRequest = async (): Promise<EndpointRequestData> => {
|
||||
if (!selectedModel) return null;
|
||||
|
||||
switch (selectedEndpoint) {
|
||||
case 'chat-completions':
|
||||
const messages = [];
|
||||
if (systemMessage.trim()) {
|
||||
messages.push({
|
||||
role: 'system' as const,
|
||||
content: systemMessage.trim(),
|
||||
});
|
||||
}
|
||||
messages.push({ role: 'user' as const, content: userMessage.trim() });
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
messages,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
} as ChatCompletionRequest;
|
||||
|
||||
case 'vision-chat':
|
||||
if (!selectedImage) {
|
||||
throw new Error('Please select an image for vision analysis');
|
||||
}
|
||||
|
||||
const imageBase64 = await fileToBase64(selectedImage);
|
||||
const visionMessages = [];
|
||||
|
||||
if (visionSystemMessage.trim()) {
|
||||
visionMessages.push({
|
||||
role: 'system' as const,
|
||||
content: visionSystemMessage.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
visionMessages.push({
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: visionUserMessage.trim(),
|
||||
},
|
||||
{
|
||||
type: 'image_url' as const,
|
||||
image_url: {
|
||||
url: imageBase64,
|
||||
detail: imageDetail,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
messages: visionMessages,
|
||||
max_tokens: visionMaxTokens,
|
||||
temperature: visionTemperature,
|
||||
} as ChatCompletionRequest;
|
||||
|
||||
case 'embeddings':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
input: embeddingInput,
|
||||
encoding_format: encodingFormat,
|
||||
} as EmbeddingRequest;
|
||||
|
||||
case 'images':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
prompt: imagePrompt,
|
||||
n: imageCount,
|
||||
size: imageSize,
|
||||
quality: imageQuality,
|
||||
style: imageStyle,
|
||||
} as ImageGenerationRequest;
|
||||
|
||||
case 'audio-speech':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
input: speechInput,
|
||||
voice: speechVoice,
|
||||
response_format: speechFormat,
|
||||
speed: speechSpeed,
|
||||
} as AudioSpeechRequest;
|
||||
|
||||
case 'audio-transcription':
|
||||
if (!recordedAudio) {
|
||||
throw new Error(
|
||||
'Please record or upload an audio file for transcription'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
file: recordedAudio,
|
||||
prompt: audioTranscriptionPrompt.trim() || undefined,
|
||||
response_format: audioResponseFormat,
|
||||
temperature: audioTemperature,
|
||||
language: audioLanguage.trim() || undefined,
|
||||
} as AudioTranscriptionRequest;
|
||||
|
||||
case 'models':
|
||||
return null; // No request body needed for models endpoint
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const testEndpointMutation = useMutation({
|
||||
mutationFn: async (requestData: EndpointRequestData) => {
|
||||
if (!selectedModel) {
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setResponse(null);
|
||||
|
||||
try {
|
||||
const response = await ModelService.testModel(
|
||||
selectedModel.id,
|
||||
selectedEndpoint,
|
||||
requestData
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Test failed');
|
||||
}
|
||||
|
||||
return response.data as ApiResponse;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to test endpoint via proxy';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResponse(data);
|
||||
toast.success(
|
||||
`${API_ENDPOINTS[selectedEndpoint].name} test completed successfully!`
|
||||
);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
const errorMessage = err?.message || 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
toast.error(
|
||||
`${API_ENDPOINTS[selectedEndpoint].name} test failed: ${errorMessage}`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!selectedModel) {
|
||||
toast.error('Please select a model to test');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields based on endpoint
|
||||
if (selectedEndpoint === 'chat-completions' && !userMessage.trim()) {
|
||||
toast.error('Please enter a test message');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'vision-chat') {
|
||||
if (!visionUserMessage.trim()) {
|
||||
toast.error('Please enter a test message');
|
||||
return;
|
||||
}
|
||||
if (!selectedImage) {
|
||||
toast.error('Please select an image for vision analysis');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'embeddings' && !embeddingInput.trim()) {
|
||||
toast.error('Please enter text for embedding');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'images' && !imagePrompt.trim()) {
|
||||
toast.error('Please enter an image prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'audio-speech' && !speechInput.trim()) {
|
||||
toast.error('Please enter text for speech synthesis');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'audio-transcription' && !recordedAudio) {
|
||||
toast.error('Please record or upload an audio file for transcription');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = await buildRequest();
|
||||
testEndpointMutation.mutate(requestData);
|
||||
};
|
||||
|
||||
const enabledModels = models.filter((model) => model.isEnabled);
|
||||
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
||||
const endpointUrl = credentials
|
||||
? buildEndpointUrl(
|
||||
credentials.baseUrl,
|
||||
API_ENDPOINTS[selectedEndpoint].path
|
||||
)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Card className='w-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>API Endpoint Tester</CardTitle>
|
||||
<CardDescription>
|
||||
Comprehensive testing of OpenAI-compatible API endpoints through the
|
||||
secure proxy (resolves CORS and network issues)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
{/* Model and Endpoint Selection */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-select'>Select Model</Label>
|
||||
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
|
||||
<SelectTrigger id='model-select'>
|
||||
<SelectValue placeholder='Choose a model to test...' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{model.name}</span>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
{model.provider}
|
||||
</Badge>
|
||||
{model.is_free && (
|
||||
<Badge variant='secondary' className='text-xs'>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='endpoint-select'>API Endpoint</Label>
|
||||
<Select
|
||||
value={selectedEndpoint}
|
||||
onValueChange={(value: EndpointType) =>
|
||||
setSelectedEndpoint(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id='endpoint-select'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(API_ENDPOINTS).map(([key, endpoint]) => {
|
||||
const Icon = endpoint.icon;
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon className='h-4 w-4' />
|
||||
<span>{endpoint.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Information */}
|
||||
{selectedModel && credentials && (
|
||||
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Globe className='h-4 w-4' />
|
||||
<span className='break-all'>
|
||||
<strong>Endpoint:</strong> {endpointUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
<span>
|
||||
<strong>API Key:</strong>{' '}
|
||||
{credentials.apiKey
|
||||
? `${credentials.apiKey.substring(0, 8)}...`
|
||||
: 'Not configured'}
|
||||
</span>
|
||||
<Badge
|
||||
variant={credentials.apiKey ? 'default' : 'destructive'}
|
||||
className='text-xs'
|
||||
>
|
||||
{selectedModel.api_key_type || 'Unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<strong>Provider:</strong> {selectedModel.provider}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<strong>Description:</strong>{' '}
|
||||
{API_ENDPOINTS[selectedEndpoint].description}
|
||||
</span>
|
||||
</div>
|
||||
{!credentials.apiKey && (
|
||||
<Alert variant='default' className='mt-2'>
|
||||
<Info className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
No API key configured for this model. Testing may still work
|
||||
if the model is free or if authentication is handled
|
||||
elsewhere. For models requiring authentication, please add an
|
||||
API key to the model or its provider group.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ApiEndpointForm
|
||||
selectedEndpoint={selectedEndpoint}
|
||||
maxTokens={maxTokens}
|
||||
setMaxTokens={setMaxTokens}
|
||||
temperature={temperature}
|
||||
setTemperature={setTemperature}
|
||||
systemMessage={systemMessage}
|
||||
setSystemMessage={setSystemMessage}
|
||||
userMessage={userMessage}
|
||||
setUserMessage={setUserMessage}
|
||||
visionMaxTokens={visionMaxTokens}
|
||||
setVisionMaxTokens={setVisionMaxTokens}
|
||||
visionTemperature={visionTemperature}
|
||||
setVisionTemperature={setVisionTemperature}
|
||||
visionSystemMessage={visionSystemMessage}
|
||||
setVisionSystemMessage={setVisionSystemMessage}
|
||||
visionUserMessage={visionUserMessage}
|
||||
setVisionUserMessage={setVisionUserMessage}
|
||||
imageDetail={imageDetail}
|
||||
setImageDetail={setImageDetail}
|
||||
selectedImage={selectedImage}
|
||||
imagePreviewUrl={imagePreviewUrl}
|
||||
onImageUpload={handleImageUpload}
|
||||
onRemoveImage={() => {
|
||||
setSelectedImage(null);
|
||||
setImagePreviewUrl(null);
|
||||
}}
|
||||
embeddingInput={embeddingInput}
|
||||
setEmbeddingInput={setEmbeddingInput}
|
||||
encodingFormat={encodingFormat}
|
||||
setEncodingFormat={setEncodingFormat}
|
||||
imageCount={imageCount}
|
||||
setImageCount={setImageCount}
|
||||
imageSize={imageSize}
|
||||
setImageSize={setImageSize}
|
||||
imageQuality={imageQuality}
|
||||
setImageQuality={setImageQuality}
|
||||
imageStyle={imageStyle}
|
||||
setImageStyle={setImageStyle}
|
||||
imagePrompt={imagePrompt}
|
||||
setImagePrompt={setImagePrompt}
|
||||
speechVoice={speechVoice}
|
||||
setSpeechVoice={setSpeechVoice}
|
||||
speechFormat={speechFormat}
|
||||
setSpeechFormat={setSpeechFormat}
|
||||
speechSpeed={speechSpeed}
|
||||
setSpeechSpeed={setSpeechSpeed}
|
||||
speechInput={speechInput}
|
||||
setSpeechInput={setSpeechInput}
|
||||
audioTranscriptionPrompt={audioTranscriptionPrompt}
|
||||
setAudioTranscriptionPrompt={setAudioTranscriptionPrompt}
|
||||
audioTemperature={audioTemperature}
|
||||
setAudioTemperature={setAudioTemperature}
|
||||
audioLanguage={audioLanguage}
|
||||
setAudioLanguage={setAudioLanguage}
|
||||
audioResponseFormat={audioResponseFormat}
|
||||
setAudioResponseFormat={setAudioResponseFormat}
|
||||
isRecording={isRecording}
|
||||
onStartRecording={startRecording}
|
||||
onStopRecording={stopRecording}
|
||||
onAudioUpload={handleAudioUpload}
|
||||
recordedAudio={recordedAudio}
|
||||
recordingUrl={recordingUrl}
|
||||
onRemoveAudio={() => {
|
||||
setRecordedAudio(null);
|
||||
setRecordingUrl(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Test Button */}
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={!selectedModelId || testEndpointMutation.isPending}
|
||||
className='w-full'
|
||||
>
|
||||
{testEndpointMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Testing {API_ENDPOINTS[selectedEndpoint].name}...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className='mr-2 h-4 w-4' />
|
||||
Test {API_ENDPOINTS[selectedEndpoint].name}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Results */}
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<XCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<strong>Test Failed:</strong> {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{response && (
|
||||
<Alert>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<strong>Test Successful!</strong>{' '}
|
||||
{API_ENDPOINTS[selectedEndpoint].name} endpoint responded
|
||||
correctly.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ApiEndpointResponse response={response} />
|
||||
|
||||
{response && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Raw Response</Label>
|
||||
<details className='group'>
|
||||
<summary className='text-muted-foreground hover:text-foreground cursor-pointer text-sm'>
|
||||
<Info className='mr-1 inline h-4 w-4' />
|
||||
Show detailed response data
|
||||
</summary>
|
||||
<pre className='bg-muted mt-2 max-h-60 overflow-auto rounded-md p-4 text-xs'>
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
156
ui/components/api-endpoint-types.ts
Normal file
156
ui/components/api-endpoint-types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
FileText,
|
||||
Eye,
|
||||
List,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
'chat-completions': {
|
||||
name: 'Chat Completions',
|
||||
path: '/chat/completions',
|
||||
icon: FileText,
|
||||
description: 'Test conversational AI with chat completion requests',
|
||||
},
|
||||
'vision-chat': {
|
||||
name: 'Vision Chat (Image + Text)',
|
||||
path: '/chat/completions',
|
||||
icon: Eye,
|
||||
description: 'Analyze images with text prompts using vision models',
|
||||
},
|
||||
embeddings: {
|
||||
name: 'Embeddings',
|
||||
path: '/embeddings',
|
||||
icon: List,
|
||||
description: 'Generate embeddings for text input',
|
||||
},
|
||||
images: {
|
||||
name: 'Image Generation',
|
||||
path: '/images/generations',
|
||||
icon: ImageIcon,
|
||||
description: 'Generate images from text prompts',
|
||||
},
|
||||
'audio-speech': {
|
||||
name: 'Text-to-Speech',
|
||||
path: '/audio/speech',
|
||||
icon: Mic,
|
||||
description: 'Convert text to speech audio',
|
||||
},
|
||||
'audio-transcription': {
|
||||
name: 'Audio Transcription',
|
||||
path: '/audio/transcriptions',
|
||||
icon: Volume2,
|
||||
description: 'Transcribe audio files to text',
|
||||
},
|
||||
models: {
|
||||
name: 'List Models',
|
||||
path: '/models',
|
||||
icon: List,
|
||||
description: 'List all available models from the provider',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type EndpointType = keyof typeof API_ENDPOINTS;
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string;
|
||||
messages: {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content:
|
||||
| string
|
||||
| Array<{
|
||||
type: 'text' | 'image_url';
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
detail?: 'low' | 'high' | 'auto';
|
||||
};
|
||||
}>;
|
||||
}[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface EmbeddingRequest {
|
||||
model: string;
|
||||
input: string | string[];
|
||||
encoding_format?: 'float' | 'base64';
|
||||
}
|
||||
|
||||
export interface ImageGenerationRequest {
|
||||
model?: string;
|
||||
prompt: string;
|
||||
n?: number;
|
||||
size?: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
|
||||
quality?: 'standard' | 'hd';
|
||||
style?: 'vivid' | 'natural';
|
||||
}
|
||||
|
||||
export interface AudioSpeechRequest {
|
||||
model: string;
|
||||
input: string;
|
||||
voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
|
||||
response_format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionRequest {
|
||||
model: string;
|
||||
file: File;
|
||||
prompt?: string;
|
||||
response_format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
|
||||
temperature?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_REQUESTS = {
|
||||
'chat-completions': {
|
||||
systemMessage: 'You are a helpful assistant. Please respond concisely.',
|
||||
userMessage:
|
||||
'Hello! Can you tell me what model you are and confirm that you are working correctly?',
|
||||
maxTokens: 150,
|
||||
temperature: 0.7,
|
||||
},
|
||||
'vision-chat': {
|
||||
systemMessage:
|
||||
'You are a helpful assistant that can analyze images. Please describe what you see.',
|
||||
userMessage:
|
||||
'What do you see in this image? Please provide a detailed description.',
|
||||
maxTokens: 300,
|
||||
temperature: 0.7,
|
||||
imageDetail: 'auto' as const,
|
||||
},
|
||||
embeddings: {
|
||||
input: 'The quick brown fox jumps over the lazy dog.',
|
||||
encoding_format: 'float' as const,
|
||||
},
|
||||
images: {
|
||||
prompt: 'A beautiful sunset over a mountain landscape',
|
||||
n: 1,
|
||||
size: '1024x1024' as const,
|
||||
quality: 'standard' as const,
|
||||
style: 'vivid' as const,
|
||||
},
|
||||
'audio-speech': {
|
||||
input: 'Hello, this is a test of the text-to-speech functionality.',
|
||||
voice: 'alloy' as const,
|
||||
response_format: 'mp3' as const,
|
||||
speed: 1.0,
|
||||
},
|
||||
'audio-transcription': {
|
||||
prompt: 'This is a test transcription.',
|
||||
response_format: 'json' as const,
|
||||
temperature: 0.0,
|
||||
language: '',
|
||||
},
|
||||
};
|
||||
|
||||
export type EndpointRequestData =
|
||||
| ChatCompletionRequest
|
||||
| EmbeddingRequest
|
||||
| ImageGenerationRequest
|
||||
| AudioSpeechRequest
|
||||
| AudioTranscriptionRequest
|
||||
| null;
|
||||
351
ui/components/app-page-shell.tsx
Normal file
351
ui/components/app-page-shell.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
DatabaseIcon,
|
||||
FileTextIcon,
|
||||
LayoutDashboardIcon,
|
||||
LogOutIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
WalletIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
import { adminLogout } from '@/lib/api/services/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyToggle } from '@/components/currency-toggle';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AppPageShellProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
|
||||
{ title: 'Balances', url: '/balances', icon: WalletIcon },
|
||||
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
|
||||
{ title: 'Models', url: '/models', icon: DatabaseIcon },
|
||||
{ title: 'Providers', url: '/providers', icon: ServerIcon },
|
||||
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
|
||||
] as const;
|
||||
|
||||
function isActivePath(pathname: string, itemUrl: string): boolean {
|
||||
if (itemUrl === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
|
||||
return pathname === itemUrl || pathname.startsWith(`${itemUrl}/`);
|
||||
}
|
||||
|
||||
export function AppPageShell({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: AppPageShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
try {
|
||||
await adminLogout();
|
||||
toast.success('Logged out successfully');
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
toast.error('Failed to logout');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-background text-foreground min-h-dvh overflow-x-clip md:h-screen md:overflow-hidden'>
|
||||
<div className='flex min-h-dvh w-full min-w-0 overflow-x-clip md:h-full'>
|
||||
<aside
|
||||
className={cn(
|
||||
'border-border/60 hidden shrink-0 border-r py-5 transition-[width,padding] duration-300 ease-in-out md:flex md:h-full md:flex-col md:overflow-y-auto',
|
||||
isSidebarCollapsed ? 'w-16 px-2' : 'w-60 px-4'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'px-1 transition-[padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed && 'px-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='shrink-0 rounded-sm'
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden transition-[max-width,opacity,transform] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'max-w-0 -translate-x-1 opacity-0'
|
||||
: 'max-w-[11rem] translate-x-0 opacity-100'
|
||||
)}
|
||||
>
|
||||
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
|
||||
Routstr Node
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground h-8 w-8 shrink-0 transition-transform duration-300 ease-in-out',
|
||||
isSidebarCollapsed ? 'mx-auto' : '-mr-1 ml-auto'
|
||||
)}
|
||||
onClick={() => setIsSidebarCollapsed((current) => !current)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelLeftOpenIcon className='h-4 w-4' />
|
||||
) : (
|
||||
<PanelLeftCloseIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='sr-only'>
|
||||
{isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'mt-5',
|
||||
isSidebarCollapsed
|
||||
? 'flex flex-col items-center space-y-2'
|
||||
: 'space-y-1.5'
|
||||
)}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActivePath(pathname, item.url);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.url}
|
||||
asChild
|
||||
variant={active ? 'outline' : 'ghost'}
|
||||
className={cn(
|
||||
'h-10 rounded-lg transition-[width,padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'mx-auto w-10 justify-center px-0'
|
||||
: 'w-full justify-start'
|
||||
)}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon className='h-4 w-4 shrink-0' />
|
||||
<span
|
||||
className={cn(
|
||||
'overflow-hidden whitespace-nowrap transition-[max-width,opacity,margin] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'ml-0 max-w-0 opacity-0'
|
||||
: 'ml-0.5 max-w-[9rem] opacity-100'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'mt-auto pt-3 transition-[padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'flex flex-col items-center space-y-1.5'
|
||||
: 'space-y-2'
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<>
|
||||
<CurrencyToggle
|
||||
compact
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='mx-auto'
|
||||
/>
|
||||
<ThemeToggle
|
||||
compact
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='mx-auto'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleLogout}
|
||||
className='text-muted-foreground hover:text-foreground mx-auto h-8 w-10 justify-center px-0'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Logout</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className='border-border/60 bg-card/30 space-y-1 rounded-lg border p-1'>
|
||||
<CurrencyToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<ThemeToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleLogout}
|
||||
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-8 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className='relative flex w-full min-w-0 flex-1 flex-col overflow-x-clip md:h-full md:min-h-0'>
|
||||
<div className='bg-background/80 supports-[backdrop-filter]:bg-background/72 sticky top-0 z-30 flex items-center gap-2 px-3 py-2 backdrop-blur-xl md:hidden'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 gap-2 rounded-lg px-3'
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
<PanelLeftOpenIcon className='h-4 w-4' />
|
||||
Menu
|
||||
</Button>
|
||||
</div>
|
||||
<main
|
||||
className={cn(
|
||||
'w-full min-w-0 flex-1 overflow-x-clip p-3 pb-4 sm:p-4 md:min-h-0 md:overflow-y-auto md:p-6 md:pb-6',
|
||||
contentClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
||||
<SheetContent
|
||||
side='left'
|
||||
showCloseButton={false}
|
||||
className='w-[min(88vw,18rem)] p-0 md:hidden'
|
||||
>
|
||||
<SheetTitle className='sr-only'>Navigation sidebar</SheetTitle>
|
||||
<SheetDescription className='sr-only'>
|
||||
Browse admin pages and access sidebar controls.
|
||||
</SheetDescription>
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border/60 px-4 pt-4 pb-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded-sm'
|
||||
/>
|
||||
<p className='truncate text-base font-medium tracking-tight'>
|
||||
Routstr Node
|
||||
</p>
|
||||
</div>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-muted-foreground hover:text-foreground h-8 w-8 shrink-0'
|
||||
>
|
||||
<PanelLeftCloseIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Close sidebar</span>
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-full min-h-0 flex-col px-3 pb-3'>
|
||||
<nav className='space-y-1.5 py-3'>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActivePath(pathname, item.url);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={`mobile-sidebar-${item.url}`}
|
||||
asChild
|
||||
variant={active ? 'outline' : 'ghost'}
|
||||
className='h-10 w-full justify-start rounded-lg'
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
>
|
||||
<Icon className='h-4 w-4' />
|
||||
{item.title}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className='border-border/60 bg-card/30 mt-auto space-y-1 rounded-lg border p-1'>
|
||||
<CurrencyToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<ThemeToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={async () => {
|
||||
setIsMobileSidebarOpen(false);
|
||||
await handleLogout();
|
||||
}}
|
||||
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-9 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
DatabaseIcon,
|
||||
LayoutDashboardIcon,
|
||||
@@ -10,15 +11,18 @@ import {
|
||||
WalletIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { NavSecondary } from '@/components/nav-secondary';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
const data = {
|
||||
@@ -48,7 +52,7 @@ const data = {
|
||||
},
|
||||
{
|
||||
title: 'Models',
|
||||
url: '/model',
|
||||
url: '/models',
|
||||
icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
@@ -68,29 +72,61 @@ const data = {
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar collapsible='offcanvas' {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarHeader className='px-3 pt-4 pb-3'>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className='data-[slot=sidebar-menu-button]:!p-1.5'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded'
|
||||
/>
|
||||
<span className='text-base font-semibold'>Routstr Node</span>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded'
|
||||
/>
|
||||
<div className='space-y-0.5'>
|
||||
<p className='text-sm font-semibold tracking-tight'>
|
||||
Routstr Node
|
||||
</p>
|
||||
<p className='text-muted-foreground text-[11px]'>
|
||||
Admin dashboard
|
||||
</p>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className='flex-1 overflow-y-auto'>
|
||||
<NavSecondary items={data.navSecondary} className='mt-auto' />
|
||||
<SidebarContent className='flex-1 overflow-y-auto px-2 pb-2'>
|
||||
<NavSecondary items={data.navSecondary} />
|
||||
<SidebarGroup className='mt-auto px-0 pt-2 pb-0'>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
|
||||
<Link
|
||||
href='https://docs.routstr.com'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<span>Docs</span>
|
||||
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
|
||||
<Link
|
||||
href='https://chat.routstr.com'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<span>Chat App</span>
|
||||
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
{/*
|
||||
<SidebarFooter>
|
||||
|
||||
38
ui/components/auth-page-shell.tsx
Normal file
38
ui/components/auth-page-shell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface AuthPageShellProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthPageShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: AuthPageShellProps) {
|
||||
return (
|
||||
<div className='bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10 sm:py-12'>
|
||||
<Card className='w-full max-w-md'>
|
||||
<CardHeader className='space-y-1 pb-4'>
|
||||
<CardTitle className='text-center text-2xl font-bold'>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-center'>
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
ui/components/batch-override-dialog.tsx
Normal file
144
ui/components/batch-override-dialog.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-[260px] font-mono text-xs sm:min-h-[400px]'
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -125,11 +125,11 @@ const chartConfig = {
|
||||
},
|
||||
desktop: {
|
||||
label: 'Desktop',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
mobile: {
|
||||
label: 'Mobile',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
|
||||
@@ -12,8 +12,28 @@ 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 { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 +50,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 +82,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 +160,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);
|
||||
@@ -114,7 +226,7 @@ export function ChildKeyCreator({
|
||||
</div>
|
||||
{costPerKeyMsats !== undefined && (
|
||||
<div className='text-right'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide'>
|
||||
Unit Cost
|
||||
</p>
|
||||
<p className='text-primary text-sm font-bold'>
|
||||
@@ -128,9 +240,9 @@ export function ChildKeyCreator({
|
||||
<div className='space-y-4'>
|
||||
{baseUrl && (
|
||||
<div className='space-y-2'>
|
||||
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
|
||||
Parent API Key
|
||||
</label>
|
||||
</Label>
|
||||
<Input
|
||||
value={activeApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
@@ -140,51 +252,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'>
|
||||
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'>
|
||||
@@ -193,12 +370,12 @@ export function ChildKeyCreator({
|
||||
|
||||
{newKeys.length > 0 && (
|
||||
<div className='mt-6 space-y-4'>
|
||||
<Alert className='border-green-200 bg-green-50 dark:border-green-900/20 dark:bg-green-900/10'>
|
||||
<AlertTitle className='text-green-800 dark:text-green-400'>
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
|
||||
Generated
|
||||
</AlertTitle>
|
||||
<AlertDescription className='text-green-700 dark:text-green-500'>
|
||||
<AlertDescription>
|
||||
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
|
||||
You won't be able to see them again.
|
||||
{resultInfo && (
|
||||
@@ -212,14 +389,14 @@ export function ChildKeyCreator({
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-xs font-medium uppercase'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
Generated Keys ({newKeys.length})
|
||||
</span>
|
||||
{newKeys.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 text-[10px] uppercase'
|
||||
className='h-7 text-[10px]'
|
||||
onClick={copyAllToClipboard}
|
||||
>
|
||||
<Copy className='mr-1 h-3 w-3' />
|
||||
@@ -243,7 +420,7 @@ export function ChildKeyCreator({
|
||||
onClick={() => copyToClipboard(key)}
|
||||
>
|
||||
{copiedKey === key ? (
|
||||
<Check className='h-3.5 w-3.5 text-green-500' />
|
||||
<Check className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
|
||||
)}
|
||||
@@ -255,15 +432,15 @@ export function ChildKeyCreator({
|
||||
|
||||
{newKeys.length > 3 && (
|
||||
<div className='space-y-2'>
|
||||
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
|
||||
Bulk Export (All Keys)
|
||||
</label>
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<textarea
|
||||
<Textarea
|
||||
readOnly
|
||||
value={newKeys.join('\n')}
|
||||
rows={Math.min(newKeys.length, 6)}
|
||||
className='bg-muted/30 w-full rounded-md border p-3 font-mono text-[10px] focus:outline-none'
|
||||
className='bg-muted/30 font-mono text-[10px] leading-relaxed'
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -281,6 +458,89 @@ 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'>
|
||||
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>Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,11 +218,11 @@ export function CollectModelsDialog({
|
||||
|
||||
{!isLoadingModels && selectedProvider && remoteModels.length > 0 && (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='text-sm font-medium'>
|
||||
{remoteModels.length} models available
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { type Model } from '@/lib/api/schemas/models';
|
||||
import { CostCalculator } from '@/components/CostCalculator';
|
||||
import { CostCalculator } from '@/components/cost-calculator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -5,8 +5,9 @@ import { type Model } from '@/lib/api/schemas/models';
|
||||
import {
|
||||
calculateRequestCost,
|
||||
estimateMinimumTokensForCost,
|
||||
formatCost,
|
||||
} from '@/lib/services/costValidation';
|
||||
} from '@/lib/services/cost-validation';
|
||||
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
|
||||
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -21,6 +22,7 @@ interface CostCalculatorProps {
|
||||
}
|
||||
|
||||
export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
const { displayUnit, usdPerSat } = useDisplayCurrency();
|
||||
const [inputTokens, setInputTokens] = useState<number>(100);
|
||||
const [outputTokens, setOutputTokens] = useState<number>(100);
|
||||
|
||||
@@ -39,6 +41,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
}, [model]);
|
||||
|
||||
const hasMinimumCost = model.min_cost_per_request > 0;
|
||||
const formatDisplayCost = (amountUsd: number) =>
|
||||
formatUsdAmountForDisplayUnit(amountUsd, displayUnit, usdPerSat);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -75,33 +79,33 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
<div className='flex justify-between'>
|
||||
<span>Input Cost ({inputTokens.toLocaleString()} tokens):</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.inputCost)}
|
||||
{formatDisplayCost(costCalculation.inputCost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Output Cost ({outputTokens.toLocaleString()} tokens):</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.outputCost)}
|
||||
{formatDisplayCost(costCalculation.outputCost)}
|
||||
</span>
|
||||
</div>
|
||||
<hr className='my-2' />
|
||||
<div className='flex justify-between'>
|
||||
<span>Base Cost:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.baseCost)}
|
||||
{formatDisplayCost(costCalculation.baseCost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Minimum Cost per Request:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.minCostPerRequest)}
|
||||
{formatDisplayCost(costCalculation.minCostPerRequest)}
|
||||
</span>
|
||||
</div>
|
||||
<hr className='my-2' />
|
||||
<div className='flex justify-between font-medium'>
|
||||
<span>Final Cost:</span>
|
||||
<span className='font-mono text-lg'>
|
||||
{formatCost(costCalculation.finalCost)}
|
||||
{formatDisplayCost(costCalculation.finalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,16 +114,12 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
{/* Minimum Cost Alert */}
|
||||
{hasMinimumCost && (
|
||||
<Alert
|
||||
className={
|
||||
costCalculation.isMinimumApplied
|
||||
? 'border-amber-200 bg-amber-50'
|
||||
: 'border-green-200 bg-green-50'
|
||||
}
|
||||
variant={costCalculation.isMinimumApplied ? 'destructive' : 'default'}
|
||||
>
|
||||
{costCalculation.isMinimumApplied ? (
|
||||
<AlertTriangle className='h-4 w-4 text-amber-600' />
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
) : (
|
||||
<CheckCircle className='h-4 w-4 text-green-600' />
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
)}
|
||||
<AlertTitle>
|
||||
{costCalculation.isMinimumApplied
|
||||
@@ -128,8 +128,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{costCalculation.isMinimumApplied
|
||||
? `The calculated cost (${formatCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatCost(costCalculation.minCostPerRequest)} is applied.`
|
||||
: `The calculated cost (${formatCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatCost(costCalculation.minCostPerRequest)}.`}
|
||||
? `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatDisplayCost(costCalculation.minCostPerRequest)} is applied.`
|
||||
: `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatDisplayCost(costCalculation.minCostPerRequest)}.`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -207,16 +207,20 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Input cost per 1M tokens:</span>
|
||||
<span className='font-mono'>{formatCost(model.input_cost)}</span>
|
||||
<span className='font-mono'>
|
||||
{formatDisplayCost(model.input_cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Output cost per 1M tokens:</span>
|
||||
<span className='font-mono'>{formatCost(model.output_cost)}</span>
|
||||
<span className='font-mono'>
|
||||
{formatDisplayCost(model.output_cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Minimum cost per request:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(model.min_cost_per_request)}
|
||||
{formatDisplayCost(model.min_cost_per_request)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useCurrencyStore } from '@/lib/stores/currency';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronsUpDownIcon, CoinsIcon } from 'lucide-react';
|
||||
import { useCurrencyStore } from '@/lib/stores/currency';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Coins } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CurrencyToggle() {
|
||||
interface CurrencyToggleProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
menuSide?: 'top' | 'right' | 'bottom' | 'left';
|
||||
menuAlign?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
const UNIT_OPTIONS: Array<{ value: DisplayUnit; label: string }> = [
|
||||
{ value: 'msat', label: 'mSAT' },
|
||||
{ value: 'sat', label: 'sat' },
|
||||
{ value: 'usd', label: 'USD' },
|
||||
];
|
||||
|
||||
function getLabel(unit: DisplayUnit): string {
|
||||
const option = UNIT_OPTIONS.find((item) => item.value === unit);
|
||||
return option?.label ?? unit;
|
||||
}
|
||||
|
||||
export function CurrencyToggle({
|
||||
className,
|
||||
compact = false,
|
||||
menuSide = 'bottom',
|
||||
menuAlign = 'end',
|
||||
}: CurrencyToggleProps) {
|
||||
const { displayUnit, setDisplayUnit } = useCurrencyStore();
|
||||
|
||||
const { data: btcUsdPrice } = useQuery({
|
||||
@@ -32,47 +57,55 @@ export function CurrencyToggle() {
|
||||
}
|
||||
}, [displayUnit, usdPerSat, setDisplayUnit]);
|
||||
|
||||
const getLabel = (unit: DisplayUnit) => {
|
||||
switch (unit) {
|
||||
case 'msat':
|
||||
return 'mSAT';
|
||||
case 'sat':
|
||||
return 'sat';
|
||||
case 'usd':
|
||||
return 'USD';
|
||||
default:
|
||||
return unit;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
|
||||
className={cn(
|
||||
'border-border/60 bg-background/65 text-muted-foreground hover:text-foreground rounded-md',
|
||||
compact
|
||||
? 'h-8 w-10 justify-center px-0'
|
||||
: 'h-8 justify-between gap-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Coins className='h-4 w-4' />
|
||||
<span className='hidden sm:inline-block'>
|
||||
{getLabel(displayUnit)}
|
||||
<span className='inline-flex min-w-0 items-center gap-1.5'>
|
||||
<CoinsIcon className='h-3.5 w-3.5 shrink-0' />
|
||||
{compact ? null : (
|
||||
<span className='truncate text-[11px] font-medium uppercase'>
|
||||
{getLabel(displayUnit)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className='uppercase sm:hidden'>{displayUnit}</span>
|
||||
{compact ? (
|
||||
<span className='sr-only'>Currency: {getLabel(displayUnit)}</span>
|
||||
) : (
|
||||
<ChevronsUpDownIcon className='h-3.5 w-3.5 opacity-70' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
|
||||
Millisatoshis (mSAT)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
|
||||
Satoshis (sat)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayUnit('usd')}
|
||||
disabled={!usdPerSat}
|
||||
<DropdownMenuContent side={menuSide} align={menuAlign}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={displayUnit}
|
||||
onValueChange={(value) => {
|
||||
if (value !== 'msat' && value !== 'sat' && value !== 'usd') return;
|
||||
if (value === 'usd' && !usdPerSat) return;
|
||||
setDisplayUnit(value);
|
||||
}}
|
||||
>
|
||||
US Dollar (USD)
|
||||
</DropdownMenuItem>
|
||||
{UNIT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === 'usd' && !usdPerSat}
|
||||
className='uppercase'
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,8 @@ export function DashboardBalanceSummary({
|
||||
queryFn: async () => {
|
||||
return WalletService.getDetailedBalances();
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchInterval: 900000,
|
||||
staleTime: 300000,
|
||||
});
|
||||
|
||||
const calculateTotals = (balances: BalanceDetail[]) => {
|
||||
@@ -59,37 +60,38 @@ export function DashboardBalanceSummary({
|
||||
title: 'Your Balance',
|
||||
value: formatAmount(totals.totalOwner),
|
||||
icon: Coins,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/20',
|
||||
color: 'text-green-600 dark:text-green-300',
|
||||
},
|
||||
{
|
||||
title: 'Total Wallet',
|
||||
value: formatAmount(totals.totalWallet),
|
||||
icon: Wallet,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
||||
color: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
title: 'User Balance',
|
||||
value: formatAmount(totals.totalUser),
|
||||
icon: User,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
|
||||
color: 'text-purple-600 dark:text-purple-300',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<div className='grid grid-cols-2 gap-2.5 max-[359px]:grid-cols-1 sm:gap-3 lg:grid-cols-3'>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
|
||||
<div className={`rounded-full p-2 ${card.bgColor}`}>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
<Card key={card.title} size='sm'>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-1'>
|
||||
<CardTitle className='text-muted-foreground text-[11px] font-medium sm:text-sm'>
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<span className='inline-flex size-6 items-center justify-center sm:size-7'>
|
||||
<card.icon className={`size-3.5 sm:size-4 ${card.color}`} />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{card.value}</div>
|
||||
<CardContent className='pt-0'>
|
||||
<div className='text-base font-semibold break-words tabular-nums sm:text-xl'>
|
||||
{card.value}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -190,11 +190,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
variant='outline'
|
||||
className='text-muted-foreground flex gap-1 px-1.5 [&_svg]:size-3'
|
||||
>
|
||||
{row.original.status === 'Done' ? (
|
||||
<CheckCircle2Icon className='text-green-500 dark:text-green-400' />
|
||||
) : (
|
||||
<LoaderIcon />
|
||||
)}
|
||||
{row.original.status === 'Done' ? <CheckCircle2Icon /> : <LoaderIcon />}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, RefreshCw, AlertCircle, Wallet } from 'lucide-react';
|
||||
import { RefreshCw, AlertCircle, Wallet, User, Coins } from 'lucide-react';
|
||||
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
|
||||
import {
|
||||
Card,
|
||||
@@ -12,6 +12,23 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { WithdrawModal } from '@/components/withdraw-modal';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
@@ -66,18 +83,46 @@ export function DetailedWalletBalance({
|
||||
? calculateTotals(data)
|
||||
: { totalWallet: 0, totalUser: 0, totalOwner: 0 };
|
||||
|
||||
const rows = (data ?? [])
|
||||
.filter(
|
||||
(detail) =>
|
||||
(detail.wallet_balance && detail.wallet_balance > 0) || detail.error
|
||||
)
|
||||
.map((detail, index) => {
|
||||
const walletMsat = convertToMsat(detail.wallet_balance || 0, detail.unit);
|
||||
const userMsat = convertToMsat(detail.user_balance || 0, detail.unit);
|
||||
const ownerMsat = convertToMsat(detail.owner_balance || 0, detail.unit);
|
||||
|
||||
return {
|
||||
key: `${detail.mint_url}-${detail.unit}-${index}`,
|
||||
detail,
|
||||
walletMsat,
|
||||
userMsat,
|
||||
ownerMsat,
|
||||
};
|
||||
});
|
||||
|
||||
const formatMintLabel = (detail: BalanceDetail) =>
|
||||
`${detail.mint_url.replace('https://', '').replace('http://', '')} • ${detail.unit.toUpperCase()}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='h-full w-full shadow-sm'>
|
||||
<Card>
|
||||
<CardHeader className='pb-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<CardTitle className='text-xl'>Cashu Wallet Balance</CardTitle>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='space-y-1.5'>
|
||||
<CardTitle>Cashu Wallet Balance</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed balance breakdown by mint and currency
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex w-full gap-2 sm:w-auto'>
|
||||
<Button
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={() => setWithdrawModalOpen(true)}
|
||||
disabled={isLoading || !data || data.length === 0}
|
||||
className='flex-1 sm:flex-none'
|
||||
>
|
||||
<Wallet className='mr-2 h-4 w-4' />
|
||||
Withdraw
|
||||
@@ -87,7 +132,6 @@ export function DetailedWalletBalance({
|
||||
size='icon'
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading || isFetching}
|
||||
className='h-8 w-8'
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
@@ -99,177 +143,218 @@ export function DetailedWalletBalance({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Detailed balance breakdown by mint and currency
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<Loader2 className='text-primary h-8 w-8 animate-spin' />
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-3 md:grid-cols-3'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={`wallet-stat-skeleton-${index}`}>
|
||||
<CardHeader className='space-y-2 pb-1'>
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
<Skeleton className='h-3 w-8' />
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<Skeleton className='h-7 w-24' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-3 w-52' />
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`wallet-row-skeleton-${index}`}
|
||||
className='h-11 w-full'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className='bg-destructive/10 text-destructive flex items-center space-x-2 rounded-md p-4'>
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-5 w-5' />
|
||||
<span>Error loading balance: {(error as Error).message}</span>
|
||||
</div>
|
||||
<AlertDescription>
|
||||
Error loading balance: {(error as Error).message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Your Balance (Total)
|
||||
</span>
|
||||
<span className='text-2xl font-bold text-green-600'>
|
||||
{formatAmount(totals.totalOwner)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Wallet
|
||||
</span>
|
||||
<span className='text-lg font-semibold'>
|
||||
{formatAmount(totals.totalWallet)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
User Balance
|
||||
</span>
|
||||
<span className='text-lg font-semibold'>
|
||||
{formatAmount(totals.totalUser)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-3'>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-muted-foreground text-sm font-medium'>
|
||||
Your Balance (Total)
|
||||
</CardTitle>
|
||||
<span className='inline-flex size-8 items-center justify-center'>
|
||||
<Coins className='size-4 text-green-600 dark:text-green-300' />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-primary text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatAmount(totals.totalOwner)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-muted-foreground text-sm font-medium'>
|
||||
Total Wallet
|
||||
</CardTitle>
|
||||
<span className='inline-flex size-8 items-center justify-center'>
|
||||
<Wallet className='size-4 text-blue-600 dark:text-blue-300' />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatAmount(totals.totalWallet)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-muted-foreground text-sm font-medium'>
|
||||
User Balance
|
||||
</CardTitle>
|
||||
<span className='inline-flex size-8 items-center justify-center'>
|
||||
<User className='size-4 text-purple-600 dark:text-purple-300' />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatAmount(totals.totalUser)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Your balance = Total wallet - User balance
|
||||
</p>
|
||||
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
{/* Desktop Table Header */}
|
||||
<div className='bg-muted hidden grid-cols-4 gap-2 p-3 text-sm font-semibold md:grid'>
|
||||
<div>Mint / Unit</div>
|
||||
<div className='text-right'>Wallet</div>
|
||||
<div className='text-right'>Users</div>
|
||||
<div className='text-right'>Owner</div>
|
||||
</div>
|
||||
|
||||
{data && data.length > 0 ? (
|
||||
data
|
||||
.filter(
|
||||
(detail) =>
|
||||
(detail.wallet_balance && detail.wallet_balance > 0) ||
|
||||
detail.error
|
||||
)
|
||||
.map((detail, index) => {
|
||||
const walletMsat = convertToMsat(
|
||||
detail.wallet_balance || 0,
|
||||
detail.unit
|
||||
);
|
||||
const userMsat = convertToMsat(
|
||||
detail.user_balance || 0,
|
||||
detail.unit
|
||||
);
|
||||
const ownerMsat = convertToMsat(
|
||||
detail.owner_balance || 0,
|
||||
detail.unit
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'border-t p-3 text-sm',
|
||||
detail.error && 'bg-destructive/10 text-destructive'
|
||||
)}
|
||||
>
|
||||
{/* Desktop Layout */}
|
||||
<div className='hidden grid-cols-4 gap-2 md:grid'>
|
||||
<div className='text-xs break-all'>
|
||||
{detail.mint_url
|
||||
.replace('https://', '')
|
||||
.replace('http://', '')}{' '}
|
||||
• {detail.unit.toUpperCase()}
|
||||
</div>
|
||||
<div className='text-right font-mono'>
|
||||
{detail.error
|
||||
? 'error'
|
||||
: formatAmount(walletMsat)}
|
||||
</div>
|
||||
<div className='text-right font-mono'>
|
||||
{detail.error ? '-' : formatAmount(userMsat)}
|
||||
</div>
|
||||
<div
|
||||
{rows.length > 0 ? (
|
||||
<>
|
||||
<div className='hidden md:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Mint / Unit</TableHead>
|
||||
<TableHead className='text-right'>Wallet</TableHead>
|
||||
<TableHead className='text-right'>Users</TableHead>
|
||||
<TableHead className='text-right'>Owner</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map(
|
||||
({
|
||||
key,
|
||||
detail,
|
||||
walletMsat,
|
||||
userMsat,
|
||||
ownerMsat,
|
||||
}) => (
|
||||
<TableRow
|
||||
key={key}
|
||||
className={cn(
|
||||
'text-right font-mono',
|
||||
!detail.error &&
|
||||
ownerMsat > 0 &&
|
||||
'font-semibold text-green-600'
|
||||
detail.error &&
|
||||
'bg-destructive/10 text-destructive'
|
||||
)}
|
||||
>
|
||||
{detail.error ? '-' : formatAmount(ownerMsat)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Layout */}
|
||||
<div className='space-y-3 md:hidden'>
|
||||
<div className='space-y-1'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
Mint / Unit
|
||||
</span>
|
||||
<div className='font-mono text-xs break-all'>
|
||||
{detail.mint_url
|
||||
.replace('https://', '')
|
||||
.replace('http://', '')}{' '}
|
||||
• {detail.unit.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Wallet
|
||||
</div>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{detail.error
|
||||
? 'error'
|
||||
: formatAmount(walletMsat)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Users
|
||||
</div>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{detail.error ? '-' : formatAmount(userMsat)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Owner
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'truncate font-mono text-sm',
|
||||
!detail.error &&
|
||||
ownerMsat > 0 &&
|
||||
'font-semibold text-green-600'
|
||||
)}
|
||||
>
|
||||
{detail.error ? '-' : formatAmount(ownerMsat)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='text-muted-foreground p-4 text-center text-sm'>
|
||||
No balances to display
|
||||
<TableCell className='max-w-md font-mono text-xs break-all whitespace-normal'>
|
||||
{formatMintLabel(detail)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{detail.error
|
||||
? 'error'
|
||||
: formatAmount(walletMsat)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{detail.error ? '-' : formatAmount(userMsat)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
'text-right font-mono',
|
||||
!detail.error &&
|
||||
ownerMsat > 0 &&
|
||||
'text-primary font-semibold'
|
||||
)}
|
||||
>
|
||||
{detail.error ? '-' : formatAmount(ownerMsat)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2 md:hidden'>
|
||||
{rows.map(
|
||||
({ key, detail, walletMsat, userMsat, ownerMsat }) => (
|
||||
<Card
|
||||
key={`${key}-mobile`}
|
||||
className={cn(
|
||||
detail.error &&
|
||||
'border-destructive/40 bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2'>
|
||||
<CardDescription className='font-mono text-xs break-all'>
|
||||
{formatMintLabel(detail)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid grid-cols-1 gap-2 p-4 pt-0 sm:grid-cols-3 sm:gap-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Wallet
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{detail.error
|
||||
? 'error'
|
||||
: formatAmount(walletMsat)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Users
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{detail.error ? '-' : formatAmount(userMsat)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Owner
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'font-mono text-sm',
|
||||
!detail.error &&
|
||||
ownerMsat > 0 &&
|
||||
'text-primary font-semibold'
|
||||
)}
|
||||
>
|
||||
{detail.error ? '-' : formatAmount(ownerMsat)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className='py-6'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Wallet className='h-4 w-4' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No balances to display</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Wallet balances will appear here after funds are
|
||||
available.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2, Copy, Check, SendIcon } from 'lucide-react';
|
||||
import { Loader2, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
@@ -101,10 +101,7 @@ export function EcashRedeem() {
|
||||
return (
|
||||
<Card className='h-full w-full shadow-sm'>
|
||||
<CardHeader>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<SendIcon className='text-primary h-5 w-5' />
|
||||
<CardTitle>Send eCash</CardTitle>
|
||||
</div>
|
||||
<CardTitle>Send eCash</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a token to send eCash to someone
|
||||
</CardDescription>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Users, Key, Loader2, Globe, AlertTriangle, Info } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
@@ -122,9 +123,9 @@ export function EditGroupForm({
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Models using group API key:</span>
|
||||
<span className='font-medium text-blue-600'>
|
||||
<Badge variant='secondary' className='tabular-nums'>
|
||||
{modelsWithoutKeys}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,18 +139,18 @@ export function EditGroupForm({
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Models using group URL:</span>
|
||||
<span className='font-medium text-blue-600'>
|
||||
<Badge variant='secondary' className='tabular-nums'>
|
||||
{modelsUsingGroupUrl}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Models with individual URLs:</span>
|
||||
<span className='font-medium text-green-600'>
|
||||
<Badge variant='outline' className='tabular-nums'>
|
||||
{models.length - modelsUsingGroupUrl}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{groupSettings?.group_url && (
|
||||
<div className='mt-2 rounded bg-blue-50 p-2 text-xs'>
|
||||
<div className='bg-muted mt-2 rounded p-2 text-xs'>
|
||||
<span className='font-medium'>Current group URL:</span>{' '}
|
||||
{groupSettings.group_url}
|
||||
</div>
|
||||
@@ -163,26 +164,22 @@ export function EditGroupForm({
|
||||
<div className='max-h-32 overflow-y-auto'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{models.map((model) => (
|
||||
<span
|
||||
<Badge
|
||||
key={model.id}
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
model.api_key
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}
|
||||
variant={model.api_key ? 'default' : 'secondary'}
|
||||
className='max-w-full'
|
||||
title={
|
||||
model.api_key
|
||||
? 'Has individual API key and URL'
|
||||
: 'Uses group API key and URL'
|
||||
}
|
||||
>
|
||||
{model.name}
|
||||
{model.api_key && ' 🔑'}
|
||||
<span className='truncate'>{model.name}</span>
|
||||
{model.api_key && <Key className='ml-1 h-3 w-3' />}
|
||||
{model.url &&
|
||||
!model.url.startsWith('/') &&
|
||||
model.api_key &&
|
||||
' 🌐'}
|
||||
</span>
|
||||
model.api_key && <Globe className='ml-1 h-3 w-3' />}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,28 +307,30 @@ export function EditGroupForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='rounded-md border border-amber-200 bg-amber-50 p-4'>
|
||||
<p className='text-sm text-amber-800'>
|
||||
<strong>How group settings work:</strong>
|
||||
</p>
|
||||
<ul className='mt-2 list-inside list-disc space-y-1 text-sm text-amber-800'>
|
||||
<li>
|
||||
Models with individual API keys and URLs will keep their
|
||||
specific settings
|
||||
</li>
|
||||
<li>
|
||||
Models without individual settings will use the group defaults
|
||||
</li>
|
||||
<li>
|
||||
Removing the group URL makes models fall back to the system
|
||||
default endpoint
|
||||
</li>
|
||||
<li>
|
||||
You can use "Apply Group Settings" to force models
|
||||
to use group configurations
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Alert>
|
||||
<Info className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<p className='font-medium'>How group settings work:</p>
|
||||
<ul className='mt-2 list-inside list-disc space-y-1 text-sm'>
|
||||
<li>
|
||||
Models with individual API keys and URLs will keep their
|
||||
specific settings
|
||||
</li>
|
||||
<li>
|
||||
Models without individual settings will use the group
|
||||
defaults
|
||||
</li>
|
||||
<li>
|
||||
Removing the group URL makes models fall back to the system
|
||||
default endpoint
|
||||
</li>
|
||||
<li>
|
||||
You can use "Apply Group Settings" to force models
|
||||
to use group configurations
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button
|
||||
@@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { type Model } from '@/lib/api/schemas/models';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -32,9 +32,9 @@ import { Switch } from '@/components/ui/switch';
|
||||
const EditModelFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional(),
|
||||
context_length: z.coerce.number().min(0),
|
||||
prompt: z.coerce.number().min(0),
|
||||
completion: z.coerce.number().min(0),
|
||||
context_length: z.number().min(0),
|
||||
prompt: z.number().min(0),
|
||||
completion: z.number().min(0),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -47,6 +47,29 @@ const roundToFiveDecimals = (value: number | undefined | null): number => {
|
||||
return Math.round(value * 100000) / 100000;
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const toStringArray = (value: unknown, fallback: string[]): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const filtered = value.filter(
|
||||
(item): item is string => typeof item === 'string'
|
||||
);
|
||||
return filtered.length > 0 ? filtered : fallback;
|
||||
};
|
||||
|
||||
interface EditModelFormProps {
|
||||
model: Model;
|
||||
providerId?: number;
|
||||
@@ -82,6 +105,81 @@ interface AdminModelData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const normalizeAdminModelData = (
|
||||
adminModel: AdminModel,
|
||||
fallbackModel: Model,
|
||||
providerId: number
|
||||
): AdminModelData => {
|
||||
const pricingRecord =
|
||||
adminModel.pricing && typeof adminModel.pricing === 'object'
|
||||
? (adminModel.pricing as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const architectureRecord =
|
||||
adminModel.architecture && typeof adminModel.architecture === 'object'
|
||||
? (adminModel.architecture as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: adminModel.id,
|
||||
name: adminModel.name,
|
||||
description: adminModel.description || '',
|
||||
created: toNumber(adminModel.created, Math.floor(Date.now() / 1000)),
|
||||
context_length: Math.max(
|
||||
0,
|
||||
Math.trunc(
|
||||
toNumber(adminModel.context_length, fallbackModel.contextLength || 4096)
|
||||
)
|
||||
),
|
||||
architecture: {
|
||||
modality:
|
||||
typeof architectureRecord.modality === 'string'
|
||||
? architectureRecord.modality
|
||||
: fallbackModel.modelType || 'text',
|
||||
input_modalities: toStringArray(architectureRecord.input_modalities, [
|
||||
fallbackModel.modelType || 'text',
|
||||
]),
|
||||
output_modalities: toStringArray(architectureRecord.output_modalities, [
|
||||
fallbackModel.modelType || 'text',
|
||||
]),
|
||||
tokenizer:
|
||||
typeof architectureRecord.tokenizer === 'string'
|
||||
? architectureRecord.tokenizer
|
||||
: '',
|
||||
instruct_type:
|
||||
typeof architectureRecord.instruct_type === 'string'
|
||||
? architectureRecord.instruct_type
|
||||
: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: roundToFiveDecimals(
|
||||
toNumber(pricingRecord.prompt, fallbackModel.input_cost)
|
||||
),
|
||||
completion: roundToFiveDecimals(
|
||||
toNumber(pricingRecord.completion, fallbackModel.output_cost)
|
||||
),
|
||||
request: toNumber(pricingRecord.request, 0),
|
||||
image: toNumber(pricingRecord.image, 0),
|
||||
web_search: toNumber(pricingRecord.web_search, 0),
|
||||
internal_reasoning: toNumber(pricingRecord.internal_reasoning, 0),
|
||||
},
|
||||
per_request_limits:
|
||||
adminModel.per_request_limits === null ||
|
||||
adminModel.per_request_limits === undefined
|
||||
? adminModel.per_request_limits
|
||||
: null,
|
||||
top_provider:
|
||||
adminModel.top_provider === null || adminModel.top_provider === undefined
|
||||
? adminModel.top_provider
|
||||
: null,
|
||||
upstream_provider_id:
|
||||
typeof adminModel.upstream_provider_id === 'number'
|
||||
? adminModel.upstream_provider_id
|
||||
: providerId,
|
||||
enabled: adminModel.enabled !== false,
|
||||
};
|
||||
};
|
||||
|
||||
export function EditModelForm({
|
||||
model,
|
||||
providerId,
|
||||
@@ -114,29 +212,28 @@ export function EditModelForm({
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Loading admin model:', {
|
||||
providerId,
|
||||
modelId: model.full_name,
|
||||
});
|
||||
|
||||
const adminModel = await AdminService.getProviderModel(
|
||||
providerId,
|
||||
model.id
|
||||
);
|
||||
|
||||
setAdminModelData(adminModel as AdminModelData);
|
||||
const normalizedAdminModel = normalizeAdminModelData(
|
||||
adminModel,
|
||||
model,
|
||||
providerId
|
||||
);
|
||||
setAdminModelData(normalizedAdminModel);
|
||||
setIsNewOverride(false);
|
||||
|
||||
form.reset({
|
||||
name: adminModel.name,
|
||||
description: adminModel.description || '',
|
||||
context_length: adminModel.context_length,
|
||||
prompt: roundToFiveDecimals(adminModel.pricing.prompt),
|
||||
completion: roundToFiveDecimals(adminModel.pricing.completion),
|
||||
enabled: adminModel.enabled !== false,
|
||||
name: normalizedAdminModel.name,
|
||||
description: normalizedAdminModel.description || '',
|
||||
context_length: normalizedAdminModel.context_length,
|
||||
prompt: normalizedAdminModel.pricing.prompt,
|
||||
completion: normalizedAdminModel.pricing.completion,
|
||||
enabled: normalizedAdminModel.enabled !== false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.log('Model not in database, will create new override:', error);
|
||||
} catch {
|
||||
setIsNewOverride(true);
|
||||
setAdminModelData({
|
||||
id: model.full_name,
|
||||
@@ -238,11 +335,9 @@ export function EditModelForm({
|
||||
};
|
||||
|
||||
if (isNewOverride) {
|
||||
console.log('Creating new model override');
|
||||
await AdminService.createProviderModel(providerId, payload);
|
||||
toast.success('Model override created successfully!');
|
||||
} else {
|
||||
console.log('Updating existing model override');
|
||||
await AdminService.updateProviderModel(
|
||||
providerId,
|
||||
adminModelData.id,
|
||||
@@ -318,7 +413,19 @@ export function EditModelForm({
|
||||
type='number'
|
||||
min='0'
|
||||
placeholder='4096'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(
|
||||
value === '' ? 0 : parseInt(value, 10) || 0
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
field.onChange(
|
||||
Number.isNaN(value) ? 0 : Math.max(0, value)
|
||||
);
|
||||
}}
|
||||
className='w-full'
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -38,8 +38,8 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
|
||||
<CardTitle>Recent Errors ({errors.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='max-h-[400px] overflow-y-auto'>
|
||||
<Table>
|
||||
<div className='max-h-[420px] max-w-full overflow-y-auto'>
|
||||
<Table className='min-w-[640px] sm:min-w-[760px]'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
|
||||
89
ui/components/key-options.tsx
Normal file
89
ui/components/key-options.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Zap, Calendar, Shield } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
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'
|
||||
name='balance_limit_msats'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</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'
|
||||
name='validity_date'
|
||||
/>
|
||||
</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 || 'none'}
|
||||
onValueChange={(value) =>
|
||||
setBalanceLimitReset(value === 'none' ? '' : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='h-9 w-full text-xs'>
|
||||
<SelectValue placeholder='None' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='none'>None</SelectItem>
|
||||
<SelectItem value='daily'>Daily</SelectItem>
|
||||
<SelectItem value='weekly'>Weekly</SelectItem>
|
||||
<SelectItem value='monthly'>Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,19 +7,9 @@ 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 { WalletBalanceStats } from './wallet-balance-stats';
|
||||
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
|
||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||
|
||||
interface ApiKeyManagerProps {
|
||||
baseUrl: string;
|
||||
@@ -51,19 +41,31 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
@@ -170,17 +172,14 @@ export function ApiKeyManager({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<RefreshCcw className='text-primary h-5 w-5' />
|
||||
API Key Management
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<CardTitle className='text-xl'>API Key Management</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide'>
|
||||
Manage your existing API keys and balances
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>Manage existing key</span>
|
||||
{walletInfo && (
|
||||
<span className='text-primary'>
|
||||
@@ -221,43 +220,15 @@ export function ApiKeyManager({
|
||||
|
||||
{showManageDetails && (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
Spendable
|
||||
</p>
|
||||
<p className='text-xl font-semibold'>
|
||||
{walletInfo
|
||||
? `${formatSats(walletInfo.balanceMsats)} sats`
|
||||
: '—'}
|
||||
</p>
|
||||
{walletInfo && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatMsats(walletInfo.balanceMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
Reserved
|
||||
</p>
|
||||
<p className='text-xl font-semibold'>
|
||||
{walletInfo
|
||||
? `${formatSats(walletInfo.reservedMsats)} sats`
|
||||
: '—'}
|
||||
</p>
|
||||
{walletInfo && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatMsats(walletInfo.reservedMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<WalletBalanceStats
|
||||
balanceMsats={walletInfo?.balanceMsats}
|
||||
reservedMsats={walletInfo?.reservedMsats}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>Refund remaining balance</span>
|
||||
</header>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import { Copy, KeyRound, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { KeyOptions } from '@/components/key-options';
|
||||
import { WalletBalanceStats } from './wallet-balance-stats';
|
||||
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,19 +50,31 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
@@ -86,9 +95,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 +132,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 +155,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 +188,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,26 +297,22 @@ 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>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<KeyRound className='text-primary h-5 w-5' />
|
||||
API key workflow
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<CardTitle className='text-xl'>API key workflow</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide'>
|
||||
Sections expand as soon as you interact
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>1 · Create key</span>
|
||||
{showCreateDetails && (
|
||||
<span className='text-primary'>Cashu token detected</span>
|
||||
@@ -285,12 +322,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,17 +344,19 @@ 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 />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>2 · Manage key</span>
|
||||
{walletInfo && (
|
||||
<span className='text-primary'>
|
||||
@@ -347,45 +395,17 @@ export function CashuPaymentWorkflow({
|
||||
</div>
|
||||
</div>
|
||||
{showManageDetails && (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
Spendable
|
||||
</p>
|
||||
<p className='text-xl font-semibold'>
|
||||
{walletInfo
|
||||
? `${formatSats(walletInfo.balanceMsats)} sats`
|
||||
: '—'}
|
||||
</p>
|
||||
{walletInfo && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatMsats(walletInfo.balanceMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
Reserved
|
||||
</p>
|
||||
<p className='text-xl font-semibold'>
|
||||
{walletInfo
|
||||
? `${formatSats(walletInfo.reservedMsats)} sats`
|
||||
: '—'}
|
||||
</p>
|
||||
{walletInfo && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatMsats(walletInfo.reservedMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<WalletBalanceStats
|
||||
balanceMsats={walletInfo?.balanceMsats}
|
||||
reservedMsats={walletInfo?.reservedMsats}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>3 · Top up</span>
|
||||
{showTopupDetails && (
|
||||
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
|
||||
@@ -428,7 +448,7 @@ export function CashuPaymentWorkflow({
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>4 · Refund</span>
|
||||
</header>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
|
||||
@@ -2,38 +2,35 @@
|
||||
|
||||
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Bolt, Copy, RefreshCcw, ShieldCheck, Terminal } from 'lucide-react';
|
||||
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
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;
|
||||
@@ -170,7 +167,7 @@ export function CheatSheet(): JSX.Element {
|
||||
const refundToken = refundReceipt?.token ?? null;
|
||||
|
||||
return (
|
||||
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
|
||||
<div className='bg-background min-h-screen'>
|
||||
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
|
||||
<section className='relative space-y-3 text-center md:text-left'>
|
||||
<div className='absolute top-0 right-0 hidden md:block'>
|
||||
@@ -178,7 +175,7 @@ export function CheatSheet(): JSX.Element {
|
||||
<a href='/login'>Admin</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider'>
|
||||
<Bolt className='text-primary h-4 w-4' />
|
||||
Routstr cheat sheet
|
||||
</div>
|
||||
@@ -196,11 +193,8 @@ export function CheatSheet(): JSX.Element {
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<ShieldCheck className='text-primary h-4 w-4' />
|
||||
Node identity
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<CardTitle className='text-lg'>Node identity</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide'>
|
||||
/v1/info snapshot
|
||||
</p>
|
||||
</div>
|
||||
@@ -217,9 +211,44 @@ export function CheatSheet(): JSX.Element {
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{isInfoLoading && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Loading node profile…
|
||||
</p>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-8 w-56 max-w-full' />
|
||||
<Skeleton className='h-4 w-80 max-w-full' />
|
||||
</div>
|
||||
<dl className='grid gap-4 sm:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-3 w-16' />
|
||||
<Skeleton className='h-5 w-24' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-3 w-12' />
|
||||
<Skeleton className='h-5 w-40 max-w-full' />
|
||||
</div>
|
||||
<div className='space-y-2 sm:col-span-2'>
|
||||
<Skeleton className='h-3 w-12' />
|
||||
<Skeleton className='h-5 w-full max-w-[26rem]' />
|
||||
</div>
|
||||
<div className='space-y-2 sm:col-span-2'>
|
||||
<Skeleton className='h-3 w-10' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-5 max-w-[28rem] flex-1' />
|
||||
<Skeleton className='h-7 w-7 rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-3 w-20' />
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`mint-skeleton-${index}`}
|
||||
className='h-6 w-28 rounded-full'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isInfoError && !isInfoLoading && (
|
||||
<p className='text-destructive text-sm'>
|
||||
@@ -236,7 +265,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</div>
|
||||
<dl className='grid gap-4 sm:grid-cols-2'>
|
||||
<div>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide'>
|
||||
Version
|
||||
</dt>
|
||||
<dd className='text-base font-medium'>
|
||||
@@ -244,7 +273,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide'>
|
||||
HTTP
|
||||
</dt>
|
||||
<dd className='text-base font-medium break-all'>
|
||||
@@ -253,7 +282,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</div>
|
||||
{nodeInfo.onion_url && (
|
||||
<div className='sm:col-span-2'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide'>
|
||||
Onion
|
||||
</dt>
|
||||
<dd className='text-base font-medium break-all'>
|
||||
@@ -263,7 +292,7 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
{nodeInfo.npub && (
|
||||
<div className='sm:col-span-2'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide'>
|
||||
npub
|
||||
</dt>
|
||||
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
|
||||
@@ -281,11 +310,11 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
</dl>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<p className='text-muted-foreground text-xs tracking-wide'>
|
||||
Cashu mints
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{nodeInfo.mints.length ? (
|
||||
{nodeInfo.mints?.length ? (
|
||||
nodeInfo.mints.map((mint) => (
|
||||
<Badge
|
||||
key={mint}
|
||||
@@ -309,11 +338,8 @@ export function CheatSheet(): JSX.Element {
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<Terminal className='text-primary h-4 w-4' />
|
||||
Quick docs
|
||||
</CardTitle>
|
||||
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<CardTitle className='text-lg'>Quick docs</CardTitle>
|
||||
<span className='text-muted-foreground text-xs tracking-wide'>
|
||||
curl-ready
|
||||
</span>
|
||||
</CardHeader>
|
||||
@@ -334,11 +360,12 @@ export function CheatSheet(): JSX.Element {
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
|
||||
<pre className='break-all whitespace-pre-wrap'>
|
||||
<ScrollArea className='bg-muted w-full rounded-lg border'>
|
||||
<pre className='w-max min-w-full p-4 font-mono text-sm leading-6 whitespace-pre'>
|
||||
{curlSnippet}
|
||||
</pre>
|
||||
</div>
|
||||
<ScrollBar orientation='horizontal' />
|
||||
</ScrollArea>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
@@ -364,10 +391,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>
|
||||
|
||||
@@ -402,7 +430,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2'>
|
||||
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>Cashu refund token</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -426,6 +454,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}
|
||||
|
||||
411
ui/components/landing/key-info-details.tsx
Normal file
411
ui/components/landing/key-info-details.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useState, useCallback, useEffect } from 'react';
|
||||
import { Copy, RefreshCcw, RotateCcw } 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='text-xl'>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='text-lg'>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'>
|
||||
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='text-lg'>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='text-lg'>
|
||||
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'>
|
||||
Requests
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
{ck.total_requests}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
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'>
|
||||
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'>
|
||||
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'>
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Copy, Zap, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { Copy, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import QRCode from 'qrcode';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -10,12 +10,9 @@ 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { KeyOptions } from '@/components/key-options';
|
||||
import type { WalletSnapshot } from './key-info-details';
|
||||
|
||||
type LightningInvoice = {
|
||||
invoice_id: string;
|
||||
@@ -39,6 +36,24 @@ interface LightningPaymentWorkflowProps {
|
||||
onApiKeyCreated?: (apiKey: string, walletInfo: WalletSnapshot) => void;
|
||||
}
|
||||
|
||||
interface InvoiceDetailsCardProps {
|
||||
label: string;
|
||||
amountSats: number;
|
||||
bolt11: string;
|
||||
qrCode: string;
|
||||
waiting: boolean;
|
||||
helperText: string;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
interface ApiKeyResultAlertProps {
|
||||
title: string;
|
||||
description: string;
|
||||
apiKey: string;
|
||||
onCopy: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
@@ -60,6 +75,113 @@ async function generateQRCodeSVG(text: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
function InvoiceDetailsCard({
|
||||
label,
|
||||
amountSats,
|
||||
bolt11,
|
||||
qrCode,
|
||||
waiting,
|
||||
helperText,
|
||||
onCopy,
|
||||
}: InvoiceDetailsCardProps): JSX.Element {
|
||||
return (
|
||||
<Card className='bg-muted/30 shadow-none'>
|
||||
<CardContent className='space-y-3 p-4'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>
|
||||
{label} ({amountSats} sats)
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 text-xs'
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{waiting && (
|
||||
<Alert>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<AlertDescription>Waiting for payment...</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{qrCode && (
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
src={qrCode}
|
||||
alt='QR Code'
|
||||
className='h-48 w-48'
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={bolt11}
|
||||
readOnly
|
||||
rows={4}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground text-center text-xs'>
|
||||
{helperText}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyResultAlert({
|
||||
title,
|
||||
description,
|
||||
apiKey,
|
||||
onCopy,
|
||||
onDismiss,
|
||||
}: ApiKeyResultAlertProps): JSX.Element {
|
||||
return (
|
||||
<Alert>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<AlertTitle className='flex items-center justify-between gap-2'>
|
||||
<span>{title}</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 text-xs'
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</AlertTitle>
|
||||
<AlertDescription className='space-y-3'>
|
||||
<Textarea
|
||||
value={apiKey}
|
||||
readOnly
|
||||
rows={2}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span>{description}</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function LightningPaymentWorkflow({
|
||||
baseUrl,
|
||||
onApiKeyCreated,
|
||||
@@ -89,7 +211,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 +291,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 +336,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 +362,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 +417,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 +478,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,29 +503,25 @@ 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>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<Zap className='text-primary h-5 w-5' />
|
||||
Lightning Payment Workflow
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<CardTitle className='text-xl'>Lightning Payment Workflow</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide'>
|
||||
Create and manage API keys using Lightning Network payments
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>1 · Create key with Lightning</span>
|
||||
{showCreateDetails && (
|
||||
<span className='text-primary'>Amount specified</span>
|
||||
@@ -367,9 +533,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}
|
||||
@@ -382,104 +557,34 @@ export function LightningPaymentWorkflow({
|
||||
</Button>
|
||||
|
||||
{createInvoice && (
|
||||
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>
|
||||
Lightning Invoice ({createInvoice.amount_sats} sats)
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 text-xs'
|
||||
onClick={() => handleCopy(createInvoice.bolt11)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isWaitingPayment && (
|
||||
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
|
||||
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
|
||||
Waiting for payment...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createQRCode && (
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
src={createQRCode}
|
||||
alt='QR Code'
|
||||
className='h-48 w-48'
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={createInvoice.bolt11}
|
||||
readOnly
|
||||
rows={4}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground text-center text-xs'>
|
||||
Scan QR code or copy invoice. Payment will be detected
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
<InvoiceDetailsCard
|
||||
label='Lightning Invoice'
|
||||
amountSats={createInvoice.amount_sats}
|
||||
bolt11={createInvoice.bolt11}
|
||||
qrCode={createQRCode}
|
||||
waiting={isWaitingPayment}
|
||||
helperText='Scan QR code or copy invoice. Payment will be detected automatically.'
|
||||
onCopy={() => handleCopy(createInvoice.bolt11)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createdApiKey && (
|
||||
<div className='space-y-3 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950'>
|
||||
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-green-700 uppercase dark:text-green-300'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<span>API Key Created Successfully</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 border-green-300 text-xs hover:bg-green-100 dark:border-green-700 dark:hover:bg-green-900'
|
||||
onClick={() => handleCopy(createdApiKey)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={createdApiKey}
|
||||
readOnly
|
||||
rows={2}
|
||||
className='border-green-200 bg-white font-mono text-xs dark:border-green-800 dark:bg-green-950/50'
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between text-xs text-green-700 dark:text-green-300'>
|
||||
<span>Your API key is ready to use!</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs text-green-700 hover:text-green-800 dark:text-green-300 dark:hover:text-green-200'
|
||||
onClick={() => setCreatedApiKey('')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ApiKeyResultAlert
|
||||
title='API Key Created Successfully'
|
||||
description='Your API key is ready to use!'
|
||||
apiKey={createdApiKey}
|
||||
onCopy={() => handleCopy(createdApiKey)}
|
||||
onDismiss={() => setCreatedApiKey('')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>2 · Top up existing key</span>
|
||||
{showTopupDetails && (
|
||||
<span className='text-primary'>Ready to create invoice</span>
|
||||
@@ -515,93 +620,25 @@ export function LightningPaymentWorkflow({
|
||||
</Button>
|
||||
|
||||
{topupInvoice && (
|
||||
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<span>Topup Invoice ({topupInvoice.amount_sats} sats)</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 text-xs'
|
||||
onClick={() => handleCopy(topupInvoice.bolt11)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{topupQRCode && (
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
src={topupQRCode}
|
||||
alt='QR Code'
|
||||
className='h-48 w-48'
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={topupInvoice.bolt11}
|
||||
readOnly
|
||||
rows={4}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground text-center text-xs'>
|
||||
Scan QR code or copy invoice. Balance will be added
|
||||
automatically.
|
||||
</p>
|
||||
|
||||
{isWaitingTopupPayment && (
|
||||
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
|
||||
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
|
||||
Waiting for payment...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InvoiceDetailsCard
|
||||
label='Topup Invoice'
|
||||
amountSats={topupInvoice.amount_sats}
|
||||
bolt11={topupInvoice.bolt11}
|
||||
qrCode={topupQRCode}
|
||||
waiting={isWaitingTopupPayment}
|
||||
helperText='Scan QR code or copy invoice. Balance will be added automatically.'
|
||||
onCopy={() => handleCopy(topupInvoice.bolt11)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{topupApiKeyResult && (
|
||||
<div className='space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950'>
|
||||
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-blue-700 uppercase dark:text-blue-300'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<span>Topup Successful</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 border-blue-300 text-xs hover:bg-blue-100 dark:border-blue-700 dark:hover:bg-blue-900'
|
||||
onClick={() => handleCopy(topupApiKeyResult)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={topupApiKeyResult}
|
||||
readOnly
|
||||
rows={2}
|
||||
className='border-blue-200 bg-white font-mono text-xs dark:border-blue-800 dark:bg-blue-950/50'
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between text-xs text-blue-700 dark:text-blue-300'>
|
||||
<span>Balance has been added to your API key!</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs text-blue-700 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200'
|
||||
onClick={() => setTopupApiKeyResult('')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ApiKeyResultAlert
|
||||
title='Topup Successful'
|
||||
description='Balance has been added to your API key!'
|
||||
apiKey={topupApiKeyResult}
|
||||
onCopy={() => handleCopy(topupApiKeyResult)}
|
||||
onDismiss={() => setTopupApiKeyResult('')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -610,7 +647,7 @@ export function LightningPaymentWorkflow({
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>3 · Recover from invoice</span>
|
||||
{showRecoverDetails && (
|
||||
<span className='text-primary'>Invoice provided</span>
|
||||
@@ -641,42 +678,13 @@ export function LightningPaymentWorkflow({
|
||||
)}
|
||||
|
||||
{recoveredApiKey && (
|
||||
<div className='space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-950'>
|
||||
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-purple-700 uppercase dark:text-purple-300'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<span>API Key Recovered</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-1 border-purple-300 text-xs hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900'
|
||||
onClick={() => handleCopy(recoveredApiKey)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={recoveredApiKey}
|
||||
readOnly
|
||||
rows={2}
|
||||
className='border-purple-200 bg-white font-mono text-xs dark:border-purple-800 dark:bg-purple-950/50'
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between text-xs text-purple-700 dark:text-purple-300'>
|
||||
<span>Your recovered API key is ready to use!</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs text-purple-700 hover:text-purple-800 dark:text-purple-300 dark:hover:text-purple-200'
|
||||
onClick={() => setRecoveredApiKey('')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ApiKeyResultAlert
|
||||
title='API Key Recovered'
|
||||
description='Your recovered API key is ready to use!'
|
||||
apiKey={recoveredApiKey}
|
||||
onCopy={() => handleCopy(recoveredApiKey)}
|
||||
onDismiss={() => setRecoveredApiKey('')}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
|
||||
63
ui/components/landing/wallet-balance-stats.tsx
Normal file
63
ui/components/landing/wallet-balance-stats.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface WalletBalanceStatsProps {
|
||||
balanceMsats?: number;
|
||||
reservedMsats?: number;
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
|
||||
function WalletMetricCard({
|
||||
label,
|
||||
valueMsats,
|
||||
}: {
|
||||
label: string;
|
||||
valueMsats?: number;
|
||||
}) {
|
||||
const hasValue = typeof valueMsats === 'number';
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='p-3 pb-2'>
|
||||
<CardDescription className='text-[0.65rem] tracking-wide'>
|
||||
{label}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='px-3 pt-0 pb-3'>
|
||||
<p className='text-xl font-semibold tabular-nums'>
|
||||
{hasValue ? `${formatSats(valueMsats)} sats` : '-'}
|
||||
</p>
|
||||
{hasValue && (
|
||||
<p className='text-muted-foreground text-xs tabular-nums'>
|
||||
{formatMsats(valueMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function WalletBalanceStats({
|
||||
balanceMsats,
|
||||
reservedMsats,
|
||||
}: WalletBalanceStatsProps) {
|
||||
return (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<WalletMetricCard label='Spendable' valueMsats={balanceMsats} />
|
||||
<WalletMetricCard label='Reserved' valueMsats={reservedMsats} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
ui/components/model-item-card.tsx
Normal file
419
ui/components/model-item-card.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ArrowRight,
|
||||
AudioLines,
|
||||
Ban,
|
||||
CheckCircle,
|
||||
Edit3,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Layers3,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Type,
|
||||
Video,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ModelItemCardProps {
|
||||
model: Model;
|
||||
displayUnit: DisplayUnit;
|
||||
usdPerSat: number | null;
|
||||
isSelected: boolean;
|
||||
hasEffectiveApiKey: boolean;
|
||||
hasIndividualSettings: boolean;
|
||||
onHoverStart: () => void;
|
||||
onHoverEnd: () => void;
|
||||
onToggleSelection: () => void;
|
||||
onEdit: () => void;
|
||||
onOverride: () => void;
|
||||
onDisable: () => void;
|
||||
onEnable: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
type ModelModality =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'file'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'embedding'
|
||||
| 'multimodal';
|
||||
|
||||
const MODALITY_ORDER: ModelModality[] = [
|
||||
'text',
|
||||
'image',
|
||||
'file',
|
||||
'audio',
|
||||
'video',
|
||||
'embedding',
|
||||
'multimodal',
|
||||
];
|
||||
|
||||
function extractModalities(part: string): ModelModality[] {
|
||||
const normalized = part.toLowerCase();
|
||||
const modalities = MODALITY_ORDER.filter((modality) =>
|
||||
normalized.includes(modality)
|
||||
);
|
||||
|
||||
return modalities.length > 0 ? modalities : ['text'];
|
||||
}
|
||||
|
||||
function getModelTypeParts(modelType: string): {
|
||||
inputs: ModelModality[];
|
||||
outputs: ModelModality[];
|
||||
} {
|
||||
const [inputPart, outputPart] = modelType
|
||||
.split('->')
|
||||
.map((part) => part.trim());
|
||||
|
||||
return {
|
||||
inputs: extractModalities(inputPart || modelType),
|
||||
outputs: outputPart ? extractModalities(outputPart) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function ModelTypeIcons({ modelType }: { modelType: string }) {
|
||||
const { inputs, outputs } = getModelTypeParts(modelType);
|
||||
|
||||
const renderIcon = (modality: ModelModality, index: number) => {
|
||||
const props = {
|
||||
className: 'h-3 w-3 shrink-0',
|
||||
'aria-hidden': true as const,
|
||||
};
|
||||
|
||||
switch (modality) {
|
||||
case 'image':
|
||||
return <ImageIcon key={`${modality}-${index}`} {...props} />;
|
||||
case 'file':
|
||||
return <FileText key={`${modality}-${index}`} {...props} />;
|
||||
case 'audio':
|
||||
return <AudioLines key={`${modality}-${index}`} {...props} />;
|
||||
case 'video':
|
||||
return <Video key={`${modality}-${index}`} {...props} />;
|
||||
case 'embedding':
|
||||
return <Waypoints key={`${modality}-${index}`} {...props} />;
|
||||
case 'multimodal':
|
||||
return <Layers3 key={`${modality}-${index}`} {...props} />;
|
||||
case 'text':
|
||||
default:
|
||||
return <Type key={`${modality}-${index}`} {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className='text-muted-foreground inline-flex max-w-full min-w-0 items-center gap-0.5 overflow-hidden'
|
||||
title={modelType}
|
||||
aria-label={modelType}
|
||||
>
|
||||
{inputs.map(renderIcon)}
|
||||
{outputs.length > 0 && (
|
||||
<>
|
||||
<ArrowRight className='h-2.5 w-2.5 shrink-0 opacity-55' aria-hidden />
|
||||
{outputs.map(renderIcon)}
|
||||
</>
|
||||
)}
|
||||
<span className='sr-only'>{modelType}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelItemCard({
|
||||
model,
|
||||
displayUnit,
|
||||
usdPerSat,
|
||||
isSelected,
|
||||
hasEffectiveApiKey,
|
||||
hasIndividualSettings,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
onToggleSelection,
|
||||
onEdit,
|
||||
onOverride,
|
||||
onDisable,
|
||||
onEnable,
|
||||
onDelete,
|
||||
}: ModelItemCardProps) {
|
||||
const isDisabled = Boolean(model.soft_deleted);
|
||||
const statusLabel = isDisabled ? 'Disabled' : 'Enabled';
|
||||
const isFreeModel = model.is_free;
|
||||
const statusDotClass = cn(
|
||||
'inline-block h-2.5 w-2.5 shrink-0 rounded-full',
|
||||
isDisabled
|
||||
? 'bg-muted-foreground/50'
|
||||
: 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.14)]'
|
||||
);
|
||||
const modelSourceLabel =
|
||||
model.api_key_type === 'remote' ? 'Remote' : 'Database';
|
||||
const keySourceLabel = !hasEffectiveApiKey
|
||||
? 'No API key'
|
||||
: hasIndividualSettings
|
||||
? 'Individual key'
|
||||
: 'Group key';
|
||||
const inputPriceLabel = formatUsdAmountForDisplayUnit(
|
||||
model.input_cost,
|
||||
displayUnit,
|
||||
usdPerSat
|
||||
);
|
||||
const outputPriceLabel = formatUsdAmountForDisplayUnit(
|
||||
model.output_cost,
|
||||
displayUnit,
|
||||
usdPerSat
|
||||
);
|
||||
const pricingLabel = isFreeModel
|
||||
? 'Free'
|
||||
: `Input ${inputPriceLabel} · Output ${outputPriceLabel}`;
|
||||
const renderActionsMenu = (className: string) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={className}
|
||||
aria-label={`Model actions for ${model.name}`}
|
||||
title={`Model actions for ${model.name}`}
|
||||
>
|
||||
<MoreVertical className='text-muted-foreground hover:text-foreground h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-52'>
|
||||
{model.api_key_type !== 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{model.api_key_type === 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOverride();
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Override
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{model.soft_deleted ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEnable();
|
||||
}}
|
||||
className='text-foreground'
|
||||
>
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
Enable Model
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDisable();
|
||||
}}
|
||||
className='text-foreground'
|
||||
>
|
||||
<Ban className='mr-2 h-4 w-4' />
|
||||
Disable Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"after:bg-border/80 relative overflow-hidden rounded-none border-0 bg-transparent py-0 shadow-none ring-0 transition-colors duration-150 after:absolute after:right-3 after:bottom-0 after:left-3 after:h-px after:content-[''] last:after:hidden md:after:hidden",
|
||||
'hover:bg-muted/25',
|
||||
isSelected && 'bg-primary/12',
|
||||
model.soft_deleted && 'bg-muted/20 opacity-80'
|
||||
)}
|
||||
onMouseEnter={onHoverStart}
|
||||
onMouseLeave={onHoverEnd}
|
||||
>
|
||||
<div className='px-2 py-2 md:flex md:items-center md:gap-2 md:py-1'>
|
||||
<div className='hidden md:flex md:items-center'>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='border-border/90 data-checked:ring-primary/40 size-4 flex-shrink-0 data-checked:ring-2'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1 px-0.5 text-left'>
|
||||
<div className='hidden min-w-0 items-center gap-x-3 gap-y-1 md:grid md:grid-cols-[minmax(210px,1.45fr)_112px_96px_120px] lg:grid-cols-[minmax(230px,1.65fr)_128px_96px_120px_minmax(220px,1fr)]'>
|
||||
<div className='min-w-0'>
|
||||
<div
|
||||
className='flex min-w-0 items-center gap-2'
|
||||
title={statusLabel}
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<span className={statusDotClass} />
|
||||
<h3
|
||||
className={cn(
|
||||
'min-w-0 truncate text-sm font-medium',
|
||||
model.soft_deleted && 'text-muted-foreground'
|
||||
)}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className='sr-only'>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className='hidden min-w-0 overflow-hidden md:flex md:items-center'
|
||||
title={model.modelType}
|
||||
>
|
||||
<ModelTypeIcons modelType={model.modelType} />
|
||||
</span>
|
||||
<span
|
||||
className='text-muted-foreground hidden truncate text-xs md:block'
|
||||
title={modelSourceLabel}
|
||||
>
|
||||
{modelSourceLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground hidden truncate text-xs md:block',
|
||||
!hasEffectiveApiKey && 'text-destructive'
|
||||
)}
|
||||
title={keySourceLabel}
|
||||
>
|
||||
{keySourceLabel}
|
||||
</span>
|
||||
<span
|
||||
className='text-muted-foreground hidden truncate text-right text-xs whitespace-nowrap lg:block'
|
||||
title={pricingLabel}
|
||||
>
|
||||
{pricingLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[16px_minmax(0,1fr)] gap-x-2.5 gap-y-2.5 md:hidden'>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='border-border/90 data-checked:ring-primary/40 mt-0.5 size-4 flex-shrink-0 self-start data-checked:ring-2'
|
||||
/>
|
||||
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2'>
|
||||
<div
|
||||
className='flex min-w-0 items-center gap-2.5'
|
||||
title={statusLabel}
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<span className={statusDotClass} />
|
||||
<h3
|
||||
className={cn(
|
||||
'line-clamp-2 min-w-0 flex-1 text-[15px] leading-5 font-medium',
|
||||
model.soft_deleted && 'text-muted-foreground'
|
||||
)}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className='sr-only'>{statusLabel}</span>
|
||||
</div>
|
||||
{renderActionsMenu(
|
||||
'hover:bg-muted/50 dark:hover:bg-muted/80 -mr-1 h-7 w-7 shrink-0 self-start'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div aria-hidden />
|
||||
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px]'>
|
||||
<span
|
||||
className='text-foreground/80 inline-flex items-center'
|
||||
title={model.modelType}
|
||||
>
|
||||
<ModelTypeIcons modelType={model.modelType} />
|
||||
</span>
|
||||
<span className='text-border/80' aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className='inline-flex items-center'>
|
||||
{modelSourceLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center',
|
||||
!hasEffectiveApiKey && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
<span className='text-border/80 mr-2' aria-hidden>
|
||||
·
|
||||
</span>
|
||||
{keySourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div aria-hidden />
|
||||
{isFreeModel ? (
|
||||
<div className='text-muted-foreground text-sm font-medium'>
|
||||
Free
|
||||
</div>
|
||||
) : (
|
||||
<div className='border-border/45 bg-background/20 grid grid-cols-2 overflow-hidden rounded-lg border'>
|
||||
<div className='px-3 py-2.5'>
|
||||
<div className='text-muted-foreground text-[11px]'>Input</div>
|
||||
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
|
||||
{inputPriceLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-border/45 border-l px-3 py-2.5'>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
Output
|
||||
</div>
|
||||
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
|
||||
{outputPriceLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='hidden md:block'>
|
||||
{renderActionsMenu(
|
||||
'hover:bg-muted/50 dark:hover:bg-muted/80 h-7 w-7 shrink-0 md:h-6 md:w-6'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
243
ui/components/model-provider-section.tsx
Normal file
243
ui/components/model-provider-section.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import type { AdminModelGroup } from '@/lib/api/services/admin';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { ModelItemCard } from '@/components/model-item-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
CheckSquare,
|
||||
Edit3,
|
||||
Globe,
|
||||
Key,
|
||||
MoreVertical,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ModelProviderSectionProps {
|
||||
provider: string;
|
||||
providerModels: Model[];
|
||||
displayUnit: DisplayUnit;
|
||||
usdPerSat: number | null;
|
||||
filterProvider?: string;
|
||||
groupData?: AdminModelGroup;
|
||||
selectedModels: Set<string>;
|
||||
onSelectProviderModels: () => void;
|
||||
onDeselectProviderModels: () => void;
|
||||
onEditGroup: () => void;
|
||||
onRefreshProviderModels: () => void;
|
||||
onDeleteAllProviderModels: () => void;
|
||||
onModelHover: (modelId: string | null) => void;
|
||||
onModelToggleSelection: (modelId: string) => void;
|
||||
onEditModel: (model: Model) => void;
|
||||
onOverrideModel: (model: Model) => void;
|
||||
onEnableModel: (modelId: string) => void;
|
||||
onDisableModel: (modelId: string) => void;
|
||||
onDeleteModel: (modelId: string) => void;
|
||||
hasEffectiveApiKey: (model: Model) => boolean;
|
||||
hasIndividualSettings: (model: Model) => boolean;
|
||||
}
|
||||
|
||||
export function ModelProviderSection({
|
||||
provider,
|
||||
providerModels,
|
||||
displayUnit,
|
||||
usdPerSat,
|
||||
filterProvider,
|
||||
groupData,
|
||||
selectedModels,
|
||||
onSelectProviderModels,
|
||||
onDeselectProviderModels,
|
||||
onEditGroup,
|
||||
onRefreshProviderModels,
|
||||
onDeleteAllProviderModels,
|
||||
onModelHover,
|
||||
onModelToggleSelection,
|
||||
onEditModel,
|
||||
onOverrideModel,
|
||||
onEnableModel,
|
||||
onDisableModel,
|
||||
onDeleteModel,
|
||||
hasEffectiveApiKey,
|
||||
hasIndividualSettings,
|
||||
}: ModelProviderSectionProps) {
|
||||
const allProviderSelected = providerModels.every((model) =>
|
||||
selectedModels.has(model.id)
|
||||
);
|
||||
const someProviderSelected = providerModels.some((model) =>
|
||||
selectedModels.has(model.id)
|
||||
);
|
||||
const keyedProviderModels = useMemo(() => {
|
||||
const seenKeys = new Map<string, number>();
|
||||
|
||||
return providerModels.map((model) => {
|
||||
const baseKey = [
|
||||
provider,
|
||||
model.id,
|
||||
model.provider_id ?? '',
|
||||
model.full_name,
|
||||
model.api_key_type,
|
||||
].join('::');
|
||||
const seenCount = seenKeys.get(baseKey) ?? 0;
|
||||
seenKeys.set(baseKey, seenCount + 1);
|
||||
|
||||
return {
|
||||
model,
|
||||
renderKey:
|
||||
seenCount === 0 ? baseKey : `${baseKey}::duplicate-${seenCount}`,
|
||||
};
|
||||
});
|
||||
}, [provider, providerModels]);
|
||||
|
||||
if (filterProvider) {
|
||||
return (
|
||||
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
|
||||
{keyedProviderModels.map(({ model, renderKey }) => (
|
||||
<ModelItemCard
|
||||
key={renderKey}
|
||||
model={model}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
isSelected={selectedModels.has(model.id)}
|
||||
hasEffectiveApiKey={hasEffectiveApiKey(model)}
|
||||
hasIndividualSettings={hasIndividualSettings(model)}
|
||||
onHoverStart={() => onModelHover(model.id)}
|
||||
onHoverEnd={() => onModelHover(null)}
|
||||
onToggleSelection={() => onModelToggleSelection(model.id)}
|
||||
onEdit={() => onEditModel(model)}
|
||||
onOverride={() => onOverrideModel(model)}
|
||||
onDisable={() => onDisableModel(model.id)}
|
||||
onEnable={() => onEnableModel(model.id)}
|
||||
onDelete={() => onDeleteModel(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='px-3 pb-2.5 sm:px-6 sm:pb-3'>
|
||||
<div className='flex items-start justify-between gap-2 sm:gap-3'>
|
||||
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
|
||||
<Checkbox
|
||||
checked={
|
||||
allProviderSelected
|
||||
? true
|
||||
: someProviderSelected
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked === true) {
|
||||
onSelectProviderModels();
|
||||
return;
|
||||
}
|
||||
|
||||
onDeselectProviderModels();
|
||||
}}
|
||||
className='border-border/90 data-checked:ring-primary/35 mt-0.5 size-4 data-checked:ring-2 sm:mt-1 sm:size-5'
|
||||
aria-label={`Select models for provider ${provider}`}
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<CardTitle className='flex items-baseline gap-1.5 text-base sm:text-lg'>
|
||||
<span className='truncate'>{provider}</span>
|
||||
<span className='text-muted-foreground text-sm font-normal'>
|
||||
({providerModels.length} models)
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm'>
|
||||
{groupData?.group_url ? (
|
||||
<span className='inline-flex items-center gap-1 break-all'>
|
||||
<Globe className='h-3 w-3' />
|
||||
{groupData.group_url}
|
||||
</span>
|
||||
) : (
|
||||
'Using default endpoint'
|
||||
)}
|
||||
{groupData?.group_api_key && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Key className='h-3 w-3' />
|
||||
Group API Key
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7 sm:h-8 sm:w-8'
|
||||
aria-label={`Provider actions for ${provider}`}
|
||||
title={`Provider actions for ${provider}`}
|
||||
>
|
||||
<MoreVertical className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-64 sm:w-72'>
|
||||
<DropdownMenuItem onClick={onEditGroup}>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Group
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onSelectProviderModels}>
|
||||
<CheckSquare className='mr-2 h-4 w-4' />
|
||||
Select All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRefreshProviderModels}>
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
Refresh Models
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onDeleteAllProviderModels}
|
||||
className='text-muted-foreground focus:text-foreground'
|
||||
>
|
||||
Delete all overrides
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
|
||||
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
|
||||
{keyedProviderModels.map(({ model, renderKey }) => (
|
||||
<ModelItemCard
|
||||
key={renderKey}
|
||||
model={model}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
isSelected={selectedModels.has(model.id)}
|
||||
hasEffectiveApiKey={hasEffectiveApiKey(model)}
|
||||
hasIndividualSettings={hasIndividualSettings(model)}
|
||||
onHoverStart={() => onModelHover(model.id)}
|
||||
onHoverEnd={() => onModelHover(null)}
|
||||
onToggleSelection={() => onModelToggleSelection(model.id)}
|
||||
onEdit={() => onEditModel(model)}
|
||||
onOverride={() => onOverrideModel(model)}
|
||||
onDisable={() => onDisableModel(model.id)}
|
||||
onEnable={() => onEnableModel(model.id)}
|
||||
onDelete={() => onDeleteModel(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -97,17 +98,22 @@ export function ModelSearchFilter({
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim() !== '' || sortOption !== 'name-asc';
|
||||
const searchInputId = 'model-search-filter-input';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-3',
|
||||
'flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='relative flex-1'>
|
||||
<div className='relative w-full min-w-0 flex-1'>
|
||||
<Label htmlFor={searchInputId} className='sr-only'>
|
||||
Search models
|
||||
</Label>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
placeholder='Search models by name, provider, or description...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
@@ -115,32 +121,36 @@ export function ModelSearchFilter({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value: SortOption) => setSortOption(value)}
|
||||
>
|
||||
<SelectTrigger className='w-full sm:w-[180px]'>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
<SelectValue placeholder='Sort by' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='name-asc'>Name (A-Z)</SelectItem>
|
||||
<SelectItem value='name-desc'>Name (Z-A)</SelectItem>
|
||||
<SelectItem value='price-asc'>Price (Low to High)</SelectItem>
|
||||
<SelectItem value='price-desc'>Price (High to Low)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className='flex items-center gap-2 sm:shrink-0'>
|
||||
<div className='min-w-0 flex-1 sm:flex-none'>
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value: SortOption) => setSortOption(value)}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full sm:w-[170px]'>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
<SelectValue placeholder='Sort by' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='name-asc'>Name (A-Z)</SelectItem>
|
||||
<SelectItem value='name-desc'>Name (Z-A)</SelectItem>
|
||||
<SelectItem value='price-asc'>Price (Low to High)</SelectItem>
|
||||
<SelectItem value='price-desc'>Price (High to Low)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={clearFilters}
|
||||
className='h-9 w-9'
|
||||
className='h-8 shrink-0 px-2 sm:size-8 sm:px-0'
|
||||
aria-label='Clear model filters'
|
||||
title='Clear filters'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<X className='hidden h-4 w-4 sm:block' />
|
||||
<span className='text-xs sm:hidden'>Clear</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
1010
ui/components/model-selector.tsx
Normal file
1010
ui/components/model-selector.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user