mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-06 09:54:36 +00:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c9ede2272 | ||
|
|
24f6519267 | ||
|
|
f57beb6411 | ||
|
|
3e41e59a1d | ||
|
|
002d750830 | ||
|
|
8c2eb55760 | ||
|
|
6fbd479bdc | ||
|
|
f67c26935a | ||
|
|
9ce91f58a9 | ||
|
|
bb82361434 | ||
|
|
5f1d67e87e | ||
|
|
b2106ad1e6 | ||
|
|
709a4ba0dc | ||
|
|
21f421b212 | ||
|
|
b3c5e4cbf6 | ||
|
|
1d95379328 | ||
|
|
48529f672a | ||
|
|
030c2f65e4 | ||
|
|
3510402af2 | ||
|
|
5f376d716d | ||
|
|
d889274f84 | ||
|
|
0c0f19d854 | ||
|
|
b61bffc666 | ||
|
|
af658136d4 | ||
|
|
8973627b5f | ||
|
|
25f427033a | ||
|
|
d3dd8318e4 | ||
|
|
52c7f17215 | ||
|
|
2c03302055 | ||
|
|
c3221f2a31 | ||
|
|
58fa063c6b | ||
|
|
7c94f60797 | ||
|
|
4cb4c6dfec | ||
|
|
512b686e5f | ||
|
|
f495a10eeb | ||
|
|
d1692edb63 | ||
|
|
8f81bcd2fc | ||
|
|
6a5ed9d063 | ||
|
|
b9890e6ad5 | ||
|
|
e4b8293d41 | ||
|
|
ba5f9fc181 | ||
|
|
795fff61e0 | ||
|
|
f9bfd4f0d2 | ||
|
|
5683382ada | ||
|
|
c92372dafd | ||
|
|
b58dd78fde | ||
|
|
4c31bf9767 | ||
|
|
42efa3c1ba | ||
|
|
74b1d39d5c | ||
|
|
4df4976f44 | ||
|
|
4aa57959bf | ||
|
|
1af39f043f | ||
|
|
1751cd3b47 | ||
|
|
b1facd58d5 | ||
|
|
6288d6fef7 | ||
|
|
a1223ad610 | ||
|
|
c75f170ed0 | ||
|
|
c6e401c3f6 | ||
|
|
1b3b206a20 | ||
|
|
248937e05f | ||
|
|
b9b477e5eb | ||
|
|
ace8cf960c | ||
|
|
3396e0cd47 | ||
|
|
31898192b2 | ||
|
|
e50facc835 | ||
|
|
55dc485705 | ||
|
|
855d60b4a5 | ||
|
|
27ace348b5 | ||
|
|
80559a57d5 | ||
|
|
8e9f6647e7 | ||
|
|
73e3d34623 | ||
|
|
c8f8857f03 | ||
|
|
c9650441bb | ||
|
|
5ce9c2217f | ||
|
|
89b8488ab8 | ||
|
|
2ee917fa31 | ||
|
|
5e12a7e92d | ||
|
|
1d043cd98d | ||
|
|
39e0959fcd | ||
|
|
29be9d5b9c | ||
|
|
1ebb7d71e1 | ||
|
|
bbf1e65a5d | ||
|
|
51c3e5dcd7 | ||
|
|
788075f656 | ||
|
|
0bbcacd186 | ||
|
|
6d5b811c20 | ||
|
|
42258ae39c | ||
|
|
04d6903369 | ||
|
|
b0b2ceb1a0 | ||
|
|
bf91f401af | ||
|
|
9dfa58d69f | ||
|
|
22ec9c3132 | ||
|
|
b72c578954 | ||
|
|
7e299180fe | ||
|
|
f290534df4 | ||
|
|
8d3c064b29 | ||
|
|
4ac96ade5f | ||
|
|
180a469399 | ||
|
|
87fbb48ca8 | ||
|
|
db021866d8 | ||
|
|
d4339287be | ||
|
|
88bcc0edcb | ||
|
|
917a4d32b1 | ||
|
|
daf17f51ab | ||
|
|
fc042c768c | ||
|
|
cb36189db3 | ||
|
|
d2487f42b0 | ||
|
|
5255fce7b2 | ||
|
|
ee668ee93b | ||
|
|
cf8b990fc7 | ||
|
|
78dd74845b | ||
|
|
e7f4c98475 |
@@ -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"]
|
||||||
+1
-1
@@ -6,7 +6,7 @@ services:
|
|||||||
context: ./ui
|
context: ./ui
|
||||||
dockerfile: Dockerfile.build
|
dockerfile: Dockerfile.build
|
||||||
args:
|
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:-}
|
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./ui_out:/output
|
- ./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
|
### Check Balance
|
||||||
|
|
||||||
Get current wallet balance.
|
Get current wallet balance.
|
||||||
@@ -434,6 +470,42 @@ Authorization: Bearer sk-...
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Create Child Key
|
||||||
|
|
||||||
|
Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/balance/child-key
|
||||||
|
Authorization: Bearer sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
|-----------|------|----------|---------|-------------|
|
||||||
|
| `count` | integer | Yes | - | Number of child keys to create (1-50) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_keys": ["sk-abc...", "sk-def..."],
|
||||||
|
"count": 2,
|
||||||
|
"cost_msats": 2000,
|
||||||
|
"cost_sats": 2,
|
||||||
|
"parent_balance": 98000,
|
||||||
|
"parent_balance_sats": 98
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Provider Discovery
|
## Provider Discovery
|
||||||
|
|
||||||
## Admin Settings
|
## Admin Settings
|
||||||
|
|||||||
@@ -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)
|
## Admin Dashboard (Primary)
|
||||||
|
|
||||||
Access the dashboard at `/admin/` on your node.
|
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):
|
Connect to your AI provider(s):
|
||||||
|
|
||||||
| Setting | Description |
|
| Setting | Description |
|
||||||
|---------|-------------|
|
| ---------------- | ------------------------------------------------ |
|
||||||
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
|
| **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
|
### Node Identity
|
||||||
|
|
||||||
How your node appears to clients:
|
How your node appears to clients:
|
||||||
|
|
||||||
| Setting | Description |
|
| Setting | Description |
|
||||||
|---------|-------------|
|
| --------------- | -------------------------------------- |
|
||||||
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
|
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
|
||||||
| **Description** | Brief description of your service |
|
| **Description** | Brief description of your service |
|
||||||
|
|
||||||
### Pricing
|
### Pricing
|
||||||
|
|
||||||
Control your profit margins:
|
Control your profit margins:
|
||||||
|
|
||||||
| Setting | Description | Default |
|
| Setting | Description | Default |
|
||||||
|---------|-------------|---------|
|
| ----------------- | ------------------------------------------ | ------------ |
|
||||||
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
|
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
|
||||||
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
|
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
|
||||||
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
|
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
|
||||||
|
|
||||||
See [Pricing](pricing.md) for detailed strategies.
|
See [Pricing](pricing.md) for detailed strategies.
|
||||||
|
|
||||||
@@ -44,33 +70,33 @@ See [Pricing](pricing.md) for detailed strategies.
|
|||||||
|
|
||||||
Which mints to accept payments from:
|
Which mints to accept payments from:
|
||||||
|
|
||||||
| Setting | Description |
|
| Setting | Description |
|
||||||
|---------|-------------|
|
| --------- | ------------------------------- |
|
||||||
| **Mints** | List of trusted Cashu mint URLs |
|
| **Mints** | List of trusted Cashu mint URLs |
|
||||||
|
|
||||||
### Lightning Withdrawals
|
### Lightning Withdrawals
|
||||||
|
|
||||||
Automatic profit withdrawal:
|
Automatic profit withdrawal:
|
||||||
|
|
||||||
| Setting | Description |
|
| Setting | Description |
|
||||||
|---------|-------------|
|
| --------------------- | ------------------------------- |
|
||||||
| **Lightning Address** | Your LN address for withdrawals |
|
| **Lightning Address** | Your LN address for withdrawals |
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
| Setting | Description |
|
| Setting | Description |
|
||||||
|---------|-------------|
|
| ------------------ | ----------------------------- |
|
||||||
| **Admin Password** | Password for dashboard access |
|
| **Admin Password** | Password for dashboard access |
|
||||||
|
|
||||||
### Nostr Discovery
|
### Nostr Discovery
|
||||||
|
|
||||||
Announce your node on the network:
|
Announce your node on the network:
|
||||||
|
|
||||||
| Setting | Description |
|
| Setting | Description |
|
||||||
|---------|-------------|
|
| ---------- | ------------------------------------ |
|
||||||
| **Npub** | Your Nostr public key |
|
| **Npub** | Your Nostr public key |
|
||||||
| **Nsec** | Your Nostr private key (for signing) |
|
| **Nsec** | Your Nostr private key (for signing) |
|
||||||
| **Relays** | Relays to publish announcements |
|
| **Relays** | Relays to publish announcements |
|
||||||
|
|
||||||
See [Discovery](discovery.md) for details.
|
See [Discovery](discovery.md) for details.
|
||||||
|
|
||||||
@@ -86,21 +112,21 @@ Use environment variables for:
|
|||||||
|
|
||||||
### All Variables
|
### All Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
| -------------------- | --------------------------------- | ------------------------------------ |
|
||||||
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
|
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
|
||||||
| `UPSTREAM_API_KEY` | Upstream API key | — |
|
| `UPSTREAM_API_KEY` | Upstream API key | — |
|
||||||
| `ADMIN_PASSWORD` | Dashboard password | (none) |
|
| `ADMIN_PASSWORD` | Dashboard password | (none) |
|
||||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
|
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
|
||||||
| `NAME` | Node display name | `ARoutstrNode` |
|
| `NAME` | Node display name | `ARoutstrNode` |
|
||||||
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
||||||
| `NPUB` | Nostr public key (bech32) | — |
|
| `NPUB` | Nostr public key (bech32) | — |
|
||||||
| `NSEC` | Nostr private key | — |
|
| `NSEC` | Nostr private key | — |
|
||||||
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
||||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||||
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
||||||
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
|
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
|
||||||
|
|
||||||
### Priority
|
### Priority
|
||||||
|
|
||||||
|
|||||||
+26
-19
@@ -6,30 +6,25 @@ Production deployment guide for Routstr Provider nodes.
|
|||||||
|
|
||||||
For production, use Docker Compose with persistent storage and optional Tor support.
|
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`:
|
```bash
|
||||||
|
docker build -f Dockerfile.full -t routstr-full .
|
||||||
```yaml
|
docker run -d -p 8000:8000 --env-file .env routstr-full
|
||||||
services:
|
|
||||||
routstr:
|
|
||||||
image: ghcr.io/routstr/proxy:latest
|
|
||||||
container_name: routstr
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./logs:/app/logs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
docker compose up -d
|
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
|
## Building from Source
|
||||||
|
|
||||||
|
### Unified Image (UI + Node)
|
||||||
|
The easiest way to build everything from source into a single production-ready image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/routstr/routstr-core.git
|
docker build -f Dockerfile.full -t routstr-full .
|
||||||
cd routstr-core
|
```
|
||||||
docker build -t routstr-local .
|
|
||||||
|
### 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.
|
You bring the API keys, Routstr handles the billing, payments, and client management.
|
||||||
|
|
||||||
!!! tip "Future: Node-to-Node Routing"
|
!!! 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
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name routstr \
|
--name routstr \
|
||||||
-p 8000:8000 \
|
-p 8000:8000 \
|
||||||
|
--env-file .env \
|
||||||
-v routstr-data:/app/data \
|
-v routstr-data:/app/data \
|
||||||
ghcr.io/routstr/proxy:latest
|
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:
|
Verify it's running:
|
||||||
|
|
||||||
```bash
|
```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/).
|
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
|
||||||
|
|
||||||
!!! note "Default Access"
|
!!! note "Login"
|
||||||
The dashboard has no password by default. Set one immediately in Settings for production use.
|
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
|
### Connect Your AI Providers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
|
print(f"Requesting {count} child keys from {base_url}...")
|
||||||
|
|
||||||
|
child_keys = []
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
try:
|
||||||
|
response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
child_keys.append(data["api_key"])
|
||||||
|
print(
|
||||||
|
f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{i + 1}] Error: {str(e)}")
|
||||||
|
|
||||||
|
return child_keys
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python create_child_keys.py <api_key_or_cashu_token> [base_url]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
auth_key = sys.argv[1]
|
||||||
|
base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
|
||||||
|
|
||||||
|
keys = create_child_keys(base_url, auth_key)
|
||||||
|
|
||||||
|
if keys:
|
||||||
|
print("\nSuccessfully created child keys:")
|
||||||
|
print(json.dumps(keys, indent=2))
|
||||||
|
else:
|
||||||
|
print("\nNo child keys were created.")
|
||||||
@@ -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,42 @@
|
|||||||
|
"""
|
||||||
|
|
||||||
|
Revision ID: a86e5348850b
|
||||||
|
Revises: b9667ffc5701
|
||||||
|
Create Date: 2026-01-10 18:57:48.475781
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "a86e5348850b"
|
||||||
|
down_revision = "b9667ffc5701"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Use batch_alter_table for SQLite compatibility
|
||||||
|
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"parent_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
batch_op.f("ix_api_keys_parent_key_hash"), ["parent_key_hash"], unique=False
|
||||||
|
)
|
||||||
|
batch_op.create_foreign_key(
|
||||||
|
"fk_api_keys_parent_key_hash",
|
||||||
|
"api_keys",
|
||||||
|
["parent_key_hash"],
|
||||||
|
["hashed_key"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint("fk_api_keys_parent_key_hash", type_="foreignkey")
|
||||||
|
batch_op.drop_index(batch_op.f("ix_api_keys_parent_key_hash"))
|
||||||
|
batch_op.drop_column("parent_key_hash")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""make upstream provider base_url + api_key unique
|
||||||
|
|
||||||
|
Revision ID: c2d3e4f5a6b7
|
||||||
|
Revises: a86e5348850b
|
||||||
|
Create Date: 2026-01-25 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "c2d3e4f5a6b7"
|
||||||
|
down_revision = "a86e5348850b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _recreate_table_sqlite(add_base_url_unique: bool) -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
existing_tables = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.exec_driver_sql(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
if "upstream_providers_old" in existing_tables:
|
||||||
|
if "upstream_providers" in existing_tables:
|
||||||
|
op.drop_table("upstream_providers_old")
|
||||||
|
else:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE upstream_providers_old RENAME TO upstream_providers"
|
||||||
|
)
|
||||||
|
existing_tables.add("upstream_providers")
|
||||||
|
if "upstream_providers" not in existing_tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
constraints = [
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"base_url",
|
||||||
|
"api_key",
|
||||||
|
name="uq_upstream_providers_base_url_api_key",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if add_base_url_unique:
|
||||||
|
constraints.append(
|
||||||
|
sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url")
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old")
|
||||||
|
op.create_table(
|
||||||
|
"upstream_providers",
|
||||||
|
sa.Column(
|
||||||
|
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||||
|
),
|
||||||
|
sa.Column("provider_type", sa.String(), nullable=False),
|
||||||
|
sa.Column("base_url", sa.String(), nullable=False),
|
||||||
|
sa.Column("api_key", sa.String(), nullable=False),
|
||||||
|
sa.Column("api_version", sa.String(), nullable=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||||
|
*constraints,
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) "
|
||||||
|
"SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee "
|
||||||
|
"FROM upstream_providers_old"
|
||||||
|
)
|
||||||
|
op.drop_table("upstream_providers_old")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
_recreate_table_sqlite(add_base_url_unique=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
for constraint in inspector.get_unique_constraints("upstream_providers"):
|
||||||
|
name = constraint.get("name")
|
||||||
|
if constraint.get("column_names") == ["base_url"] and name:
|
||||||
|
op.drop_constraint(
|
||||||
|
name,
|
||||||
|
"upstream_providers",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||||
|
if "ix_upstream_providers_base_url" in index_names:
|
||||||
|
op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers")
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_upstream_providers_base_url_api_key",
|
||||||
|
"upstream_providers",
|
||||||
|
["base_url", "api_key"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
_recreate_table_sqlite(add_base_url_unique=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_upstream_providers_base_url_api_key",
|
||||||
|
"upstream_providers",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_upstream_providers_base_url",
|
||||||
|
"upstream_providers",
|
||||||
|
["base_url"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_upstream_providers_base_url",
|
||||||
|
"upstream_providers",
|
||||||
|
["base_url"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "routstr"
|
name = "routstr"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+52
-96
@@ -84,93 +84,26 @@ def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
|
|||||||
return penalty
|
return penalty
|
||||||
|
|
||||||
|
|
||||||
def should_prefer_model(
|
|
||||||
candidate_model: "Model",
|
|
||||||
candidate_provider: "BaseUpstreamProvider",
|
|
||||||
current_model: "Model",
|
|
||||||
current_provider: "BaseUpstreamProvider",
|
|
||||||
alias: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Determine if candidate model should replace current model for an alias.
|
|
||||||
|
|
||||||
This is the core decision function for model prioritization. It considers:
|
|
||||||
1. Alias matching quality (exact match vs. canonical slug match)
|
|
||||||
2. Model cost (lower is better)
|
|
||||||
3. Provider penalties (e.g., slight preference against OpenRouter)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
candidate_model: The new model being considered
|
|
||||||
candidate_provider: Provider offering the candidate model
|
|
||||||
current_model: The currently selected model for this alias
|
|
||||||
current_provider: Provider offering the current model
|
|
||||||
alias: The model alias being mapped
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if candidate should replace current, False otherwise
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_base_model_id(model_id: str) -> str:
|
|
||||||
"""Get base model ID by removing provider prefix."""
|
|
||||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
|
||||||
|
|
||||||
def alias_priority(model: "Model") -> int:
|
|
||||||
"""Rank how strong the mapping of alias->model is.
|
|
||||||
|
|
||||||
Highest priority when alias exactly equals the model ID without provider prefix.
|
|
||||||
Next when alias equals canonical slug without prefix. Otherwise lowest.
|
|
||||||
"""
|
|
||||||
model_base = get_base_model_id(model.id)
|
|
||||||
if model_base == alias:
|
|
||||||
return 3
|
|
||||||
if model.canonical_slug:
|
|
||||||
canonical_base = get_base_model_id(model.canonical_slug)
|
|
||||||
if canonical_base == alias:
|
|
||||||
return 2
|
|
||||||
return 1
|
|
||||||
|
|
||||||
candidate_alias_priority = alias_priority(candidate_model)
|
|
||||||
current_alias_priority = alias_priority(current_model)
|
|
||||||
|
|
||||||
# If candidate has better alias match, prefer it regardless of cost
|
|
||||||
if candidate_alias_priority > current_alias_priority:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# If current has better alias match, keep it regardless of cost
|
|
||||||
if current_alias_priority > candidate_alias_priority:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Same alias priority - compare costs
|
|
||||||
candidate_cost = calculate_model_cost_score(candidate_model)
|
|
||||||
current_cost = calculate_model_cost_score(current_model)
|
|
||||||
|
|
||||||
# Apply provider penalties
|
|
||||||
candidate_adjusted = candidate_cost * get_provider_penalty(candidate_provider)
|
|
||||||
current_adjusted = current_cost * get_provider_penalty(current_provider)
|
|
||||||
|
|
||||||
# Prefer lower adjusted cost
|
|
||||||
should_replace = candidate_adjusted < current_adjusted
|
|
||||||
|
|
||||||
return should_replace
|
|
||||||
|
|
||||||
|
|
||||||
def create_model_mappings(
|
def create_model_mappings(
|
||||||
upstreams: list["BaseUpstreamProvider"],
|
upstreams: list["BaseUpstreamProvider"],
|
||||||
overrides_by_id: dict[str, tuple],
|
overrides_by_id: dict[str, tuple],
|
||||||
disabled_model_ids: set[str],
|
disabled_model_ids: set[str],
|
||||||
) -> tuple[dict[str, "Model"], dict[str, "BaseUpstreamProvider"], dict[str, "Model"]]:
|
) -> tuple[
|
||||||
|
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
|
||||||
|
]:
|
||||||
"""Create optimal model mappings based on cost and provider preferences.
|
"""Create optimal model mappings based on cost and provider preferences.
|
||||||
|
|
||||||
This is the main entry point for the algorithm. It processes all upstream providers
|
This is the main entry point for the algorithm. It processes all upstream providers
|
||||||
and creates three mappings based on cost optimization:
|
and creates three mappings based on cost optimization:
|
||||||
|
|
||||||
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
|
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
|
||||||
2. provider_map: alias -> UpstreamProvider (which provider to use for each alias)
|
2. provider_map: alias -> List[UpstreamProvider] (sorted list of providers for each alias)
|
||||||
3. unique_models: base_id -> Model (unique models without provider prefixes)
|
3. unique_models: base_id -> Model (unique models without provider prefixes)
|
||||||
|
|
||||||
The algorithm:
|
The algorithm:
|
||||||
- Processes non-OpenRouter providers first (they're typically cheaper)
|
- Processes non-OpenRouter providers first (they're typically cheaper)
|
||||||
- Then processes OpenRouter models (they can still win if cheaper)
|
- Then processes OpenRouter models (they can still win if cheaper)
|
||||||
- For each model alias, uses should_prefer_model() to select the best provider
|
- For each model alias, collects all candidates and sorts them by priority and cost.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
upstreams: List of all upstream provider instances
|
upstreams: List of all upstream provider instances
|
||||||
@@ -183,8 +116,7 @@ def create_model_mappings(
|
|||||||
from .payment.models import _row_to_model
|
from .payment.models import _row_to_model
|
||||||
from .upstream.helpers import resolve_model_alias
|
from .upstream.helpers import resolve_model_alias
|
||||||
|
|
||||||
model_instances: dict[str, "Model"] = {}
|
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
|
||||||
provider_map: dict[str, "BaseUpstreamProvider"] = {}
|
|
||||||
unique_models: dict[str, "Model"] = {}
|
unique_models: dict[str, "Model"] = {}
|
||||||
|
|
||||||
# Separate OpenRouter from other providers
|
# Separate OpenRouter from other providers
|
||||||
@@ -202,24 +134,14 @@ def create_model_mappings(
|
|||||||
"""Get base model ID by removing provider prefix."""
|
"""Get base model ID by removing provider prefix."""
|
||||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||||
|
|
||||||
def _maybe_set_alias(
|
def _add_candidate(
|
||||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set alias to model/provider if not set or if new model is preferred."""
|
"""Add candidate model/provider for an alias."""
|
||||||
alias_lower = alias.lower()
|
alias_lower = alias.lower()
|
||||||
existing_model = model_instances.get(alias_lower)
|
if alias_lower not in candidates:
|
||||||
if not existing_model:
|
candidates[alias_lower] = []
|
||||||
# No existing mapping, set it
|
candidates[alias_lower].append((model, provider))
|
||||||
model_instances[alias_lower] = model
|
|
||||||
provider_map[alias_lower] = provider
|
|
||||||
else:
|
|
||||||
# Check if candidate should replace existing
|
|
||||||
existing_provider = provider_map[alias_lower]
|
|
||||||
if should_prefer_model(
|
|
||||||
model, provider, existing_model, existing_provider, alias
|
|
||||||
):
|
|
||||||
model_instances[alias_lower] = model
|
|
||||||
provider_map[alias_lower] = provider
|
|
||||||
|
|
||||||
def process_provider_models(
|
def process_provider_models(
|
||||||
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
||||||
@@ -266,21 +188,55 @@ def create_model_mappings(
|
|||||||
|
|
||||||
# Try to set each alias
|
# Try to set each alias
|
||||||
for alias in aliases:
|
for alias in aliases:
|
||||||
_maybe_set_alias(alias, model_to_use, upstream)
|
_add_candidate(alias, model_to_use, upstream)
|
||||||
|
|
||||||
# Process non-OpenRouter providers first (they're typically cheaper)
|
# Process non-OpenRouter providers first
|
||||||
for upstream in other_upstreams:
|
for upstream in other_upstreams:
|
||||||
process_provider_models(upstream, is_openrouter=False)
|
process_provider_models(upstream, is_openrouter=False)
|
||||||
|
|
||||||
# Process OpenRouter last - models only win if they're cheaper or better matched
|
# Process OpenRouter last
|
||||||
if openrouter:
|
if openrouter:
|
||||||
process_provider_models(openrouter, is_openrouter=True)
|
process_provider_models(openrouter, is_openrouter=True)
|
||||||
|
|
||||||
# Log provider distribution
|
# Sort candidates and build final maps
|
||||||
|
model_instances: dict[str, "Model"] = {}
|
||||||
|
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||||
|
|
||||||
|
def alias_priority(model: "Model", alias: str) -> int:
|
||||||
|
"""Rank how strong the mapping of alias->model is."""
|
||||||
|
model_base = get_base_model_id(model.id)
|
||||||
|
if model_base == alias:
|
||||||
|
return 3
|
||||||
|
if model.canonical_slug:
|
||||||
|
canonical_base = get_base_model_id(model.canonical_slug)
|
||||||
|
if canonical_base == alias:
|
||||||
|
return 2
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for alias, items in candidates.items():
|
||||||
|
# Sort key: (priority DESC, cost ASC)
|
||||||
|
# Using negative cost for DESC sort overall to keep high priority first
|
||||||
|
def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
|
||||||
|
model, provider = item
|
||||||
|
priority = alias_priority(model, alias)
|
||||||
|
cost = calculate_model_cost_score(model)
|
||||||
|
penalty = get_provider_penalty(provider)
|
||||||
|
adjusted_cost = cost * penalty
|
||||||
|
return (priority, -adjusted_cost)
|
||||||
|
|
||||||
|
items.sort(key=sort_key, reverse=True)
|
||||||
|
|
||||||
|
best_model, best_provider = items[0]
|
||||||
|
model_instances[alias] = best_model
|
||||||
|
provider_map[alias] = [p for _, p in items]
|
||||||
|
|
||||||
|
# Log provider distribution (using top provider for stats)
|
||||||
provider_counts: dict[str, int] = {}
|
provider_counts: dict[str, int] = {}
|
||||||
for provider in provider_map.values():
|
for providers in provider_map.values():
|
||||||
provider_name = getattr(provider, "upstream_name", "unknown")
|
if providers:
|
||||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
provider = providers[0]
|
||||||
|
provider_name = getattr(provider, "upstream_name", "unknown")
|
||||||
|
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
||||||
|
|||||||
+423
-41
@@ -1,10 +1,14 @@
|
|||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import math
|
import math
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlmodel import col, update
|
from sqlmodel import col, select, update
|
||||||
|
|
||||||
from .core import get_logger
|
from .core import get_logger
|
||||||
from .core.db import ApiKey, AsyncSession
|
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
|
# 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(
|
async def validate_bearer_key(
|
||||||
bearer_key: str,
|
bearer_key: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
refund_address: Optional[str] = None,
|
refund_address: Optional[str] = None,
|
||||||
key_expiry_time: Optional[int] = None,
|
key_expiry_time: Optional[int] = None,
|
||||||
|
min_cost: int = 0,
|
||||||
) -> ApiKey:
|
) -> ApiKey:
|
||||||
"""
|
"""
|
||||||
Validates the provided API key using SQLModel.
|
Validates the provided API key using SQLModel.
|
||||||
If it's a cashu key, it redeems it and stores its hash and balance.
|
If it's a cashu key, it redeems it and stores its hash and balance.
|
||||||
Otherwise checks if the hash of the key exists.
|
Otherwise checks if the hash of the key exists.
|
||||||
|
Includes a balance check against min_cost for limited keys.
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Starting bearer key validation",
|
"Starting bearer key validation",
|
||||||
@@ -43,6 +91,7 @@ async def validate_bearer_key(
|
|||||||
else bearer_key,
|
else bearer_key,
|
||||||
"has_refund_address": bool(refund_address),
|
"has_refund_address": bool(refund_address),
|
||||||
"has_expiry_time": bool(key_expiry_time),
|
"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
|
return existing_key
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
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
|
return existing_key
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -286,30 +392,57 @@ async def validate_bearer_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
|
||||||
|
"""Returns the key that should be charged for the request."""
|
||||||
|
if key.parent_key_hash:
|
||||||
|
parent = await session.get(ApiKey, key.parent_key_hash)
|
||||||
|
if parent:
|
||||||
|
# We want to keep the total_requests and total_spent on the child key
|
||||||
|
# but use the balance and reserved_balance of the parent.
|
||||||
|
# However, pay_for_request updates reserved_balance and total_requests.
|
||||||
|
# To stay simple, we charge the parent's balance and update parent's total_requests.
|
||||||
|
return parent
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Parent key not found for child key",
|
||||||
|
extra={
|
||||||
|
"child_key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"parent_key_hash": key.parent_key_hash[:8] + "...",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
async def pay_for_request(
|
async def pay_for_request(
|
||||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Process payment for a request."""
|
"""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)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Processing payment for request",
|
"Processing payment for request",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"current_balance": key.balance,
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"current_balance": billing_key.balance,
|
||||||
"required_cost": cost_per_request,
|
"required_cost": cost_per_request,
|
||||||
"sufficient_balance": key.balance >= cost_per_request,
|
"sufficient_balance": billing_key.balance >= cost_per_request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if key.total_balance < cost_per_request:
|
if billing_key.total_balance < cost_per_request:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Insufficient balance for request",
|
"Insufficient balance for request",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"balance": key.balance,
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"reserved_balance": key.reserved_balance,
|
"balance": billing_key.balance,
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
"required": cost_per_request,
|
"required": cost_per_request,
|
||||||
"shortfall": cost_per_request - key.total_balance,
|
"shortfall": cost_per_request - billing_key.total_balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -317,26 +450,78 @@ async def pay_for_request(
|
|||||||
status_code=402,
|
status_code=402,
|
||||||
detail={
|
detail={
|
||||||
"error": {
|
"error": {
|
||||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.total_balance} available. (reserved: {billing_key.reserved_balance})",
|
||||||
"type": "insufficient_quota",
|
"type": "insufficient_quota",
|
||||||
"code": "insufficient_balance",
|
"code": "insufficient_balance",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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(
|
logger.debug(
|
||||||
"Charging base cost for request",
|
"Charging base cost for request",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"cost": cost_per_request,
|
"cost": cost_per_request,
|
||||||
"balance_before": key.balance,
|
"balance_before": billing_key.balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Charge the base cost for the request atomically to avoid race conditions
|
# Charge the base cost for the request atomically to avoid race conditions
|
||||||
stmt = (
|
stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||||
@@ -344,6 +529,19 @@ async def pay_for_request(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
@@ -351,8 +549,9 @@ async def pay_for_request(
|
|||||||
"Concurrent request depleted balance",
|
"Concurrent request depleted balance",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"required_cost": cost_per_request,
|
"required_cost": cost_per_request,
|
||||||
"current_balance": key.balance,
|
"current_balance": billing_key.balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -361,23 +560,26 @@ async def pay_for_request(
|
|||||||
status_code=402,
|
status_code=402,
|
||||||
detail={
|
detail={
|
||||||
"error": {
|
"error": {
|
||||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||||
"type": "insufficient_quota",
|
"type": "insufficient_quota",
|
||||||
"code": "insufficient_balance",
|
"code": "insufficient_balance",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Payment processed successfully",
|
"Payment processed successfully",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"charged_amount": cost_per_request,
|
"charged_amount": cost_per_request,
|
||||||
"new_balance": key.balance,
|
"new_balance": billing_key.balance,
|
||||||
"total_spent": key.total_spent,
|
"total_spent": billing_key.total_spent,
|
||||||
"total_requests": key.total_requests,
|
"total_requests": billing_key.total_requests,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -387,9 +589,11 @@ async def pay_for_request(
|
|||||||
async def revert_pay_for_request(
|
async def revert_pay_for_request(
|
||||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||||
total_requests=col(ApiKey.total_requests) - 1,
|
total_requests=col(ApiKey.total_requests) - 1,
|
||||||
@@ -397,27 +601,43 @@ async def revert_pay_for_request(
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to revert payment - insufficient reserved balance",
|
"Failed to revert payment - insufficient reserved balance",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"cost_to_revert": cost_per_request,
|
"cost_to_revert": cost_per_request,
|
||||||
"current_reserved_balance": key.reserved_balance,
|
"current_reserved_balance": billing_key.reserved_balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=402,
|
status_code=402,
|
||||||
detail={
|
detail={
|
||||||
"error": {
|
"error": {
|
||||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||||
"type": "payment_error",
|
"type": "payment_error",
|
||||||
"code": "payment_error",
|
"code": "payment_error",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
|
|
||||||
async def adjust_payment_for_tokens(
|
async def adjust_payment_for_tokens(
|
||||||
@@ -428,15 +648,17 @@ async def adjust_payment_for_tokens(
|
|||||||
This is called after the initial payment and the upstream request is complete.
|
This is called after the initial payment and the upstream request is complete.
|
||||||
Returns cost data to be included in the response.
|
Returns cost data to be included in the response.
|
||||||
"""
|
"""
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
model = response_data.get("model", "unknown")
|
model = response_data.get("model", "unknown")
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Starting payment adjustment for tokens",
|
"Starting payment adjustment for tokens",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"model": model,
|
"model": model,
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
"current_balance": key.balance,
|
"current_balance": billing_key.balance,
|
||||||
"has_usage": "usage" in response_data,
|
"has_usage": "usage" in response_data,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -446,22 +668,42 @@ async def adjust_payment_for_tokens(
|
|||||||
try:
|
try:
|
||||||
release_stmt = (
|
release_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(reserved_balance=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]
|
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# Also release on child key if it's different
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_release_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.values(
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
|
- deducted_max_cost
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(child_release_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Released reservation without charging (fallback)",
|
"Released reservation without charging (fallback)",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to release reservation in fallback",
|
"Failed to release reservation in fallback",
|
||||||
extra={"error": str(e), "key_hash": key.hashed_key[:8] + "..."},
|
extra={
|
||||||
|
"error": str(e),
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||||
@@ -470,6 +712,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"Using max cost data (no token adjustment)",
|
"Using max cost data (no token adjustment)",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_cost": cost.total_msats,
|
"max_cost": cost.total_msats,
|
||||||
},
|
},
|
||||||
@@ -477,7 +720,7 @@ async def adjust_payment_for_tokens(
|
|||||||
# Finalize by releasing reservation and charging max cost
|
# Finalize by releasing reservation and charging max cost
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||||
balance=col(ApiKey.balance) - cost.total_msats,
|
balance=col(ApiKey.balance) - cost.total_msats,
|
||||||
@@ -485,27 +728,45 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
|
- deducted_max_cost,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to finalize max-cost payment - retrying reservation release",
|
"Failed to finalize max-cost payment - retrying reservation release",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
"current_reserved_balance": key.reserved_balance,
|
"current_reserved_balance": billing_key.reserved_balance,
|
||||||
"total_cost": cost.total_msats,
|
"total_cost": cost.total_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await release_reservation_only()
|
await release_reservation_only()
|
||||||
else:
|
else:
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Max cost payment finalized",
|
"Max cost payment finalized",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"charged_amount": cost.total_msats,
|
"charged_amount": cost.total_msats,
|
||||||
"new_balance": key.balance,
|
"new_balance": billing_key.balance,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -521,6 +782,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"Calculated token-based cost",
|
"Calculated token-based cost",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"model": model,
|
"model": model,
|
||||||
"token_cost": cost.total_msats,
|
"token_cost": cost.total_msats,
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
@@ -533,11 +795,15 @@ async def adjust_payment_for_tokens(
|
|||||||
if cost_difference == 0:
|
if cost_difference == 0:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Finalizing with exact reserved cost",
|
"Finalizing with exact reserved cost",
|
||||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
extra={
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"model": model,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
- deducted_max_cost,
|
- deducted_max_cost,
|
||||||
@@ -546,8 +812,24 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
|
- deducted_max_cost,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
return cost.dict()
|
return cost.dict()
|
||||||
|
|
||||||
# this should never happen why do we handle this???
|
# this should never happen why do we handle this???
|
||||||
@@ -557,16 +839,17 @@ async def adjust_payment_for_tokens(
|
|||||||
"Additional charge required for token usage",
|
"Additional charge required for token usage",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"additional_charge": cost_difference,
|
"additional_charge": cost_difference,
|
||||||
"current_balance": key.balance,
|
"current_balance": billing_key.balance,
|
||||||
"sufficient_balance": key.balance >= cost_difference,
|
"sufficient_balance": billing_key.balance >= cost_difference,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
- deducted_max_cost,
|
- deducted_max_cost,
|
||||||
@@ -575,18 +858,35 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
|
- deducted_max_cost,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if result.rowcount:
|
if result.rowcount:
|
||||||
cost.total_msats = total_cost_msats
|
cost.total_msats = total_cost_msats
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Finalized payment with additional charge",
|
"Finalized payment with additional charge",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"charged_amount": total_cost_msats,
|
"charged_amount": total_cost_msats,
|
||||||
"new_balance": key.balance,
|
"new_balance": billing_key.balance,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -595,6 +895,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"Failed to finalize additional charge - releasing reservation",
|
"Failed to finalize additional charge - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"attempted_charge": total_cost_msats,
|
"attempted_charge": total_cost_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
@@ -607,15 +908,16 @@ async def adjust_payment_for_tokens(
|
|||||||
"Refunding excess payment",
|
"Refunding excess payment",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"refund_amount": refund,
|
"refund_amount": refund,
|
||||||
"current_balance": key.balance,
|
"current_balance": billing_key.balance,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
refund_stmt = (
|
refund_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
- deducted_max_cost,
|
- deducted_max_cost,
|
||||||
@@ -624,6 +926,20 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
|
- deducted_max_cost,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
@@ -631,8 +947,9 @@ async def adjust_payment_for_tokens(
|
|||||||
"Failed to finalize payment - releasing reservation",
|
"Failed to finalize payment - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
"current_reserved_balance": key.reserved_balance,
|
"current_reserved_balance": billing_key.reserved_balance,
|
||||||
"total_cost": total_cost_msats,
|
"total_cost": total_cost_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
@@ -640,14 +957,17 @@ async def adjust_payment_for_tokens(
|
|||||||
await release_reservation_only()
|
await release_reservation_only()
|
||||||
else:
|
else:
|
||||||
cost.total_msats = total_cost_msats
|
cost.total_msats = total_cost_msats
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Refund processed successfully",
|
"Refund processed successfully",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"refunded_amount": refund,
|
"refunded_amount": refund,
|
||||||
"new_balance": key.balance,
|
"new_balance": billing_key.balance,
|
||||||
"final_cost": cost.total_msats,
|
"final_cost": cost.total_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
@@ -689,3 +1009,65 @@ async def adjust_payment_for_tokens(
|
|||||||
"output_msats": 0,
|
"output_msats": 0,
|
||||||
"total_msats": deducted_max_cost,
|
"total_msats": deducted_max_cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def periodic_key_reset() -> None:
|
||||||
|
"""Background task to reset key limits based on their policy."""
|
||||||
|
from .core.db import create_session
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
interval = 3600 # Run every hour
|
||||||
|
jitter = 300
|
||||||
|
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with create_session() as session:
|
||||||
|
# Find all keys that have a reset policy
|
||||||
|
stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore
|
||||||
|
keys = (await session.exec(stmt)).all()
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
reset_date = key.balance_limit_reset_date or 0
|
||||||
|
should_reset = False
|
||||||
|
|
||||||
|
if key.balance_limit_reset == "daily":
|
||||||
|
if (
|
||||||
|
datetime.fromtimestamp(now).date()
|
||||||
|
> datetime.fromtimestamp(reset_date).date()
|
||||||
|
):
|
||||||
|
should_reset = True
|
||||||
|
elif key.balance_limit_reset == "weekly":
|
||||||
|
if (
|
||||||
|
datetime.fromtimestamp(now).isocalendar()[:2]
|
||||||
|
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
|
||||||
|
):
|
||||||
|
should_reset = True
|
||||||
|
elif key.balance_limit_reset == "monthly":
|
||||||
|
dt_now = datetime.fromtimestamp(now)
|
||||||
|
dt_reset = datetime.fromtimestamp(reset_date)
|
||||||
|
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
|
||||||
|
should_reset = True
|
||||||
|
|
||||||
|
if should_reset:
|
||||||
|
key.total_spent = 0
|
||||||
|
key.balance_limit_reset_date = now
|
||||||
|
session.add(key)
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
if updated_count > 0:
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Periodic key reset complete",
|
||||||
|
extra={"keys_reset": updated_count},
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in periodic_key_reset: {e}")
|
||||||
|
|||||||
+189
-17
@@ -1,12 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import time
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Annotated, NoReturn
|
from typing import Annotated, NoReturn
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
from pydantic import BaseModel
|
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.db import ApiKey, AsyncSession, get_session
|
||||||
from .core.logging import get_logger
|
from .core.logging import get_logger
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
@@ -32,14 +34,49 @@ async def get_key_from_header(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
|
info = {
|
||||||
|
"api_key": "sk-" + key.hashed_key,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"reserved": billing_key.reserved_balance,
|
||||||
|
"is_child": key.parent_key_hash is not None,
|
||||||
|
"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
|
# TODO: remove this endpoint when frontend is updated
|
||||||
@router.get("/", include_in_schema=False)
|
@router.get("/", include_in_schema=False)
|
||||||
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
async def account_info(
|
||||||
return {
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
"api_key": "sk-" + key.hashed_key,
|
session: AsyncSession = Depends(get_session),
|
||||||
"balance": key.balance,
|
) -> dict:
|
||||||
"reserved": key.reserved_balance,
|
return await get_balance_info(key, session)
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: Implement POST /v1/wallet/create endpoint
|
# TODO: Implement POST /v1/wallet/create endpoint
|
||||||
@@ -56,9 +93,24 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
|||||||
|
|
||||||
@router.get("/create")
|
@router.get("/create")
|
||||||
async def create_balance(
|
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:
|
) -> dict:
|
||||||
key = await validate_bearer_key(initial_balance_token, session)
|
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 {
|
return {
|
||||||
"api_key": "sk-" + key.hashed_key,
|
"api_key": "sk-" + key.hashed_key,
|
||||||
"balance": key.balance,
|
"balance": key.balance,
|
||||||
@@ -66,12 +118,11 @@ async def create_balance(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/info")
|
@router.get("/info")
|
||||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
async def wallet_info(
|
||||||
return {
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
"api_key": "sk-" + key.hashed_key,
|
session: AsyncSession = Depends(get_session),
|
||||||
"balance": key.balance,
|
) -> dict:
|
||||||
"reserved": key.reserved_balance,
|
return await get_balance_info(key, session)
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TopupRequest(BaseModel):
|
class TopupRequest(BaseModel):
|
||||||
@@ -85,6 +136,8 @@ async def topup_wallet_endpoint(
|
|||||||
key: ApiKey = Depends(get_key_from_header),
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
|
|
||||||
if topup_request is not None:
|
if topup_request is not None:
|
||||||
cashu_token = topup_request.cashu_token
|
cashu_token = topup_request.cashu_token
|
||||||
if cashu_token is None:
|
if cashu_token is None:
|
||||||
@@ -94,7 +147,7 @@ async def topup_wallet_endpoint(
|
|||||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||||
try:
|
try:
|
||||||
amount_msats = await credit_balance(cashu_token, key, session)
|
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "already spent" in error_msg.lower():
|
if "already spent" in error_msg.lower():
|
||||||
@@ -150,10 +203,16 @@ async def refund_wallet_endpoint(
|
|||||||
|
|
||||||
bearer_value: str = authorization[7:]
|
bearer_value: str = authorization[7:]
|
||||||
|
|
||||||
|
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||||
|
|
||||||
if cached := await _refund_cache_get(bearer_value):
|
if cached := await _refund_cache_get(bearer_value):
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
if key.parent_key_hash:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot refund child key. Please refund the parent key instead.",
|
||||||
|
)
|
||||||
|
|
||||||
remaining_balance_msats: int = key.total_balance
|
remaining_balance_msats: int = key.total_balance
|
||||||
|
|
||||||
@@ -209,7 +268,9 @@ async def refund_wallet_endpoint(
|
|||||||
|
|
||||||
await _refund_cache_set(bearer_value, result)
|
await _refund_cache_set(bearer_value, result)
|
||||||
|
|
||||||
await session.delete(key)
|
key.balance = 0
|
||||||
|
key.reserved_balance = 0
|
||||||
|
session.add(key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -228,6 +289,117 @@ async def donate(token: str, ref: str | None = None) -> str:
|
|||||||
return "Invalid token."
|
return "Invalid token."
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
async def create_child_key(
|
||||||
|
payload: ChildKeyRequest,
|
||||||
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Creates one or more child API keys that use the parent's balance."""
|
||||||
|
# Log incoming request for debugging
|
||||||
|
logger.debug(f"Child key creation request: count={payload.count}")
|
||||||
|
|
||||||
|
count = payload.count
|
||||||
|
if count < 1 or count > 50:
|
||||||
|
raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
|
||||||
|
|
||||||
|
# Check if this is already a child key
|
||||||
|
if key.parent_key_hash:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot create a child key for another child key.",
|
||||||
|
)
|
||||||
|
|
||||||
|
cost_per_key = settings.child_key_cost
|
||||||
|
total_cost = cost_per_key * count
|
||||||
|
|
||||||
|
if key.total_balance < total_cost:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduct cost from parent
|
||||||
|
key.balance -= total_cost
|
||||||
|
key.total_spent += total_cost
|
||||||
|
session.add(key)
|
||||||
|
|
||||||
|
# Generate new keys
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
new_keys = []
|
||||||
|
for _ in range(count):
|
||||||
|
new_key_raw = secrets.token_hex(32)
|
||||||
|
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
|
||||||
|
|
||||||
|
child_key = ApiKey(
|
||||||
|
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)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"api_keys": new_keys,
|
||||||
|
"count": count,
|
||||||
|
"cost_msats": total_cost,
|
||||||
|
"cost_sats": total_cost // 1000,
|
||||||
|
"parent_balance": key.balance,
|
||||||
|
"parent_balance_sats": key.balance // 1000,
|
||||||
|
}
|
||||||
|
logger.debug(f"Child key creation response: {response_data}")
|
||||||
|
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(
|
@router.api_route(
|
||||||
"/{path:path}",
|
"/{path:path}",
|
||||||
methods=["GET", "POST", "PUT", "DELETE"],
|
methods=["GET", "POST", "PUT", "DELETE"],
|
||||||
|
|||||||
+146
-2226
File diff suppressed because it is too large
Load Diff
+72
-3
@@ -1,10 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -47,6 +50,25 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
|||||||
default=None,
|
default=None,
|
||||||
description="Currency of the cashu-token",
|
description="Currency of the cashu-token",
|
||||||
)
|
)
|
||||||
|
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
|
@property
|
||||||
def total_balance(self) -> int:
|
def total_balance(self) -> int:
|
||||||
@@ -108,11 +130,16 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
|||||||
|
|
||||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||||
__tablename__ = "upstream_providers"
|
__tablename__ = "upstream_providers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"base_url", "api_key", name="uq_upstream_providers_base_url_api_key"
|
||||||
|
),
|
||||||
|
)
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
provider_type: str = Field(
|
provider_type: str = Field(
|
||||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||||
)
|
)
|
||||||
base_url: str = Field(unique=True, description="Base URL of the upstream API")
|
base_url: str = Field(description="Base URL of the upstream API")
|
||||||
api_key: str = Field(description="API key for the upstream provider")
|
api_key: str = Field(description="API key for the upstream provider")
|
||||||
api_version: str | None = Field(
|
api_version: str | None = Field(
|
||||||
default=None, description="API version for Azure OpenAI"
|
default=None, description="API version for Azure OpenAI"
|
||||||
@@ -156,11 +183,53 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
yield session
|
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:
|
def run_migrations() -> None:
|
||||||
"""Run Alembic migrations programmatically."""
|
"""Run Alembic migrations programmatically."""
|
||||||
import pathlib
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Run Cashu migration fix first
|
||||||
|
fix_cashu_migrations()
|
||||||
|
|
||||||
# Get the path to the alembic.ini file
|
# Get the path to the alembic.ini file
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[2]
|
project_root = pathlib.Path(__file__).resolve().parents[2]
|
||||||
alembic_ini_path = project_root / "alembic.ini"
|
alembic_ini_path = project_root / "alembic.ini"
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ from .logging import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamError(Exception):
|
||||||
|
"""Exception raised when an upstream provider fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 502):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
"""Handle HTTP exceptions and include request ID in response."""
|
"""Handle HTTP exceptions and include request ID in response."""
|
||||||
request_id = getattr(request.state, "request_id", "unknown")
|
request_id = getattr(request.state, "request_id", "unknown")
|
||||||
|
|||||||
+13
-8
@@ -10,13 +10,11 @@ from fastapi.responses import FileResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from ..auth import periodic_key_reset
|
||||||
from ..balance import balance_router, deprecated_wallet_router
|
from ..balance import balance_router, deprecated_wallet_router
|
||||||
from ..discovery import providers_cache_refresher, providers_router
|
from ..nostr import announce_provider, providers_cache_refresher
|
||||||
from ..nip91 import announce_provider
|
from ..nostr.discovery import providers_router
|
||||||
from ..payment.models import (
|
from ..payment.models import models_router, update_sats_pricing
|
||||||
models_router,
|
|
||||||
update_sats_pricing,
|
|
||||||
)
|
|
||||||
from ..payment.price import update_prices_periodically
|
from ..payment.price import update_prices_periodically
|
||||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||||
from ..wallet import periodic_payout
|
from ..wallet import periodic_payout
|
||||||
@@ -33,9 +31,9 @@ setup_logging()
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
if os.getenv("VERSION_SUFFIX") is not None:
|
if os.getenv("VERSION_SUFFIX") is not None:
|
||||||
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
|
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
|
||||||
else:
|
else:
|
||||||
__version__ = "0.2.2"
|
__version__ = "0.3.0"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -49,6 +47,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
providers_task = None
|
providers_task = None
|
||||||
models_refresh_task = None
|
models_refresh_task = None
|
||||||
model_maps_refresh_task = None
|
model_maps_refresh_task = None
|
||||||
|
key_reset_task = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run database migrations on startup
|
# Run database migrations on startup
|
||||||
@@ -104,6 +103,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
nip91_task = asyncio.create_task(announce_provider())
|
nip91_task = asyncio.create_task(announce_provider())
|
||||||
if global_settings.providers_refresh_interval_seconds > 0:
|
if global_settings.providers_refresh_interval_seconds > 0:
|
||||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||||
|
key_reset_task = asyncio.create_task(periodic_key_reset())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -133,6 +133,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
models_refresh_task.cancel()
|
models_refresh_task.cancel()
|
||||||
if model_maps_refresh_task is not None:
|
if model_maps_refresh_task is not None:
|
||||||
model_maps_refresh_task.cancel()
|
model_maps_refresh_task.cancel()
|
||||||
|
if key_reset_task is not None:
|
||||||
|
key_reset_task.cancel()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tasks_to_wait = []
|
tasks_to_wait = []
|
||||||
@@ -150,6 +152,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
tasks_to_wait.append(models_refresh_task)
|
tasks_to_wait.append(models_refresh_task)
|
||||||
if model_maps_refresh_task is not None:
|
if model_maps_refresh_task is not None:
|
||||||
tasks_to_wait.append(model_maps_refresh_task)
|
tasks_to_wait.append(model_maps_refresh_task)
|
||||||
|
if key_reset_task is not None:
|
||||||
|
tasks_to_wait.append(key_reset_task)
|
||||||
|
|
||||||
if tasks_to_wait:
|
if tasks_to_wait:
|
||||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||||
@@ -191,6 +195,7 @@ async def info() -> dict:
|
|||||||
"mints": global_settings.cashu_mints,
|
"mints": global_settings.cashu_mints,
|
||||||
"http_url": global_settings.http_url,
|
"http_url": global_settings.http_url,
|
||||||
"onion_url": global_settings.onion_url,
|
"onion_url": global_settings.onion_url,
|
||||||
|
"child_key_cost_msats": global_settings.child_key_cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class Settings(BaseSettings):
|
|||||||
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
||||||
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
||||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||||
|
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
|
||||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
||||||
reset_reserved_balance_on_startup: bool = Field(
|
reset_reserved_balance_on_startup: bool = Field(
|
||||||
@@ -142,7 +143,7 @@ def resolve_bootstrap() -> Settings:
|
|||||||
pass
|
pass
|
||||||
if not base.onion_url:
|
if not base.onion_url:
|
||||||
try:
|
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()
|
discovered = discover_onion_url_from_tor()
|
||||||
if discovered:
|
if discovered:
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class InvoiceCreateRequest(BaseModel):
|
|||||||
api_key: str | None = Field(
|
api_key: str | None = Field(
|
||||||
default=None, description="Required for topup operations"
|
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):
|
class InvoiceCreateResponse(BaseModel):
|
||||||
@@ -94,6 +97,9 @@ async def create_invoice(
|
|||||||
status="pending",
|
status="pending",
|
||||||
api_key_hash=request.api_key[3:] if request.api_key else None,
|
api_key_hash=request.api_key[3:] if request.api_key else None,
|
||||||
purpose=request.purpose,
|
purpose=request.purpose,
|
||||||
|
balance_limit=request.balance_limit,
|
||||||
|
balance_limit_reset=request.balance_limit_reset,
|
||||||
|
validity_date=request.validity_date,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
import websockets
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from .core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
from .core.settings import settings
|
from ..core.settings import settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -72,8 +72,6 @@ async def query_nostr_relay_for_providers(
|
|||||||
elif data[0] == "NOTICE":
|
elif data[0] == "NOTICE":
|
||||||
try:
|
try:
|
||||||
msg = str(data[1])
|
msg = str(data[1])
|
||||||
if len(msg) > 200:
|
|
||||||
msg = msg[:200] + "..."
|
|
||||||
logger.debug(f"Relay notice: {msg}")
|
logger.debug(f"Relay notice: {msg}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Relay notice received")
|
logger.debug("Relay notice received")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
NIP-91: Routstr Provider Discoverability Implementation
|
Listing: Routstr Provider Discoverability Implementation
|
||||||
Automatically announces this Routstr proxy instance to Nostr relays.
|
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.message_type import ClientMessageType
|
||||||
from nostr.relay_manager import RelayManager
|
from nostr.relay_manager import RelayManager
|
||||||
|
|
||||||
from .core import get_logger
|
from ..core import get_logger
|
||||||
from .core.settings import settings
|
from ..core.settings import settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_app_version() -> str | None:
|
def get_app_version() -> str | None:
|
||||||
try:
|
try:
|
||||||
from .core.main import __version__ as imported_version
|
from ..core.main import __version__ as imported_version
|
||||||
|
|
||||||
return imported_version
|
return imported_version
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -71,7 +71,7 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_nip91_event(
|
def create_listing_event(
|
||||||
private_key_hex: str,
|
private_key_hex: str,
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
endpoint_urls: list[str],
|
endpoint_urls: list[str],
|
||||||
@@ -80,7 +80,7 @@ def create_nip91_event(
|
|||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a NIP-91 compliant provider announcement event (kind:38421).
|
Create a listing provider announcement event (kind:38421).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
private_key_hex: 32-byte hex private key for signing
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def query_nip91_events(
|
async def query_listing_events(
|
||||||
relay_url: str,
|
relay_url: str,
|
||||||
pubkey: str,
|
pubkey: str,
|
||||||
provider_id: str | None = None,
|
provider_id: str | None = None,
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
) -> tuple[list[dict[str, Any]], bool]:
|
) -> 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
|
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
|
||||||
succeeded without transport-level errors.
|
succeeded without transport-level errors.
|
||||||
@@ -188,7 +188,7 @@ async def query_nip91_events(
|
|||||||
|
|
||||||
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
|
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
|
||||||
filters = Filters([flt])
|
filters = Filters([flt])
|
||||||
sub_id = f"nip91_{int(time.time())}"
|
sub_id = f"routstr_listing_{int(time.time())}"
|
||||||
rm.add_subscription(sub_id, filters)
|
rm.add_subscription(sub_id, filters)
|
||||||
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
|
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
|
||||||
req.extend(filters.to_json_array())
|
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]]:
|
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
|
||||||
try:
|
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
|
return events
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
@@ -330,7 +330,7 @@ async def publish_to_relay(
|
|||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
) -> bool:
|
) -> 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:
|
def _sync_publish() -> bool:
|
||||||
@@ -341,7 +341,7 @@ async def publish_to_relay(
|
|||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
# Publish the event as-is via publish_message to preserve signature
|
# Publish the event as-is via publish_message to preserve signature
|
||||||
rm.publish_message(json.dumps(["EVENT", event]))
|
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)
|
time.sleep(1.0)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -364,13 +364,13 @@ async def announce_provider() -> None:
|
|||||||
# Check for NSEC in environment (use NSEC only)
|
# Check for NSEC in environment (use NSEC only)
|
||||||
nsec = settings.nsec
|
nsec = settings.nsec
|
||||||
if not 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
|
return
|
||||||
|
|
||||||
# Convert NSEC to keypair
|
# Convert NSEC to keypair
|
||||||
keypair = nsec_to_keypair(nsec)
|
keypair = nsec_to_keypair(nsec)
|
||||||
if not keypair:
|
if not keypair:
|
||||||
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
|
logger.error("Failed to parse NSEC, skipping listing announcement")
|
||||||
return
|
return
|
||||||
|
|
||||||
private_key_hex, public_key_hex = keypair
|
private_key_hex, public_key_hex = keypair
|
||||||
@@ -409,7 +409,7 @@ async def announce_provider() -> None:
|
|||||||
|
|
||||||
if not endpoint_urls:
|
if not endpoint_urls:
|
||||||
logger.warning(
|
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
|
return
|
||||||
|
|
||||||
@@ -434,7 +434,7 @@ async def announce_provider() -> None:
|
|||||||
|
|
||||||
# Create the candidate event that we would publish
|
# Create the candidate event that we would publish
|
||||||
version_str = get_app_version()
|
version_str = get_app_version()
|
||||||
candidate_event = create_nip91_event(
|
candidate_event = create_listing_event(
|
||||||
private_key_hex=private_key_hex,
|
private_key_hex=private_key_hex,
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
endpoint_urls=endpoint_urls,
|
endpoint_urls=endpoint_urls,
|
||||||
@@ -474,7 +474,7 @@ async def announce_provider() -> None:
|
|||||||
if _should_skip(relay_url):
|
if _should_skip(relay_url):
|
||||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||||
continue
|
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:
|
if ok:
|
||||||
_register_success(relay_url)
|
_register_success(relay_url)
|
||||||
existing_events.extend(events)
|
existing_events.extend(events)
|
||||||
@@ -489,7 +489,7 @@ async def announce_provider() -> None:
|
|||||||
|
|
||||||
if not all_match:
|
if not all_match:
|
||||||
logger.debug(
|
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
|
success_count = 0
|
||||||
for relay_url in relay_urls:
|
for relay_url in relay_urls:
|
||||||
@@ -502,11 +502,11 @@ async def announce_provider() -> None:
|
|||||||
else:
|
else:
|
||||||
_register_failure(relay_url)
|
_register_failure(relay_url)
|
||||||
logger.info(
|
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:
|
else:
|
||||||
logger.debug(
|
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)
|
# Re-announce periodically (every 24 hours)
|
||||||
@@ -518,7 +518,7 @@ async def announce_provider() -> None:
|
|||||||
|
|
||||||
# Build fresh candidate event for comparison
|
# Build fresh candidate event for comparison
|
||||||
version_str = get_app_version()
|
version_str = get_app_version()
|
||||||
candidate_event = create_nip91_event(
|
candidate_event = create_listing_event(
|
||||||
private_key_hex=private_key_hex,
|
private_key_hex=private_key_hex,
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
endpoint_urls=endpoint_urls,
|
endpoint_urls=endpoint_urls,
|
||||||
@@ -533,7 +533,7 @@ async def announce_provider() -> None:
|
|||||||
if _should_skip(relay_url):
|
if _should_skip(relay_url):
|
||||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||||
continue
|
continue
|
||||||
events, ok = await query_nip91_events(
|
events, ok = await query_listing_events(
|
||||||
relay_url, public_key_hex, provider_id
|
relay_url, public_key_hex, provider_id
|
||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
@@ -549,7 +549,7 @@ async def announce_provider() -> None:
|
|||||||
|
|
||||||
if all_match:
|
if all_match:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Matching NIP-91 announcement already present; skipping periodic re-announce"
|
"Matching listing announcement already present; skipping periodic re-announce"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -567,8 +567,8 @@ async def announce_provider() -> None:
|
|||||||
_register_failure(relay_url)
|
_register_failure(relay_url)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("NIP-91 announcement task cancelled")
|
logger.info("Listing announcement task cancelled")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
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
|
# Continue running despite errors
|
||||||
@@ -15,6 +15,7 @@ class CostData(BaseModel):
|
|||||||
input_msats: int
|
input_msats: int
|
||||||
output_msats: int
|
output_msats: int
|
||||||
total_msats: int
|
total_msats: int
|
||||||
|
total_usd: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class MaxCostData(CostData):
|
class MaxCostData(CostData):
|
||||||
@@ -61,6 +62,7 @@ async def calculate_cost( # todo: can be sync
|
|||||||
input_msats=0,
|
input_msats=0,
|
||||||
output_msats=0,
|
output_msats=0,
|
||||||
total_msats=0,
|
total_msats=0,
|
||||||
|
total_usd=0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
usage_data = response_data["usage"]
|
usage_data = response_data["usage"]
|
||||||
@@ -101,6 +103,7 @@ async def calculate_cost( # todo: can be sync
|
|||||||
input_msats=-1, # Cost field doesn't break down by token type
|
input_msats=-1, # Cost field doesn't break down by token type
|
||||||
output_msats=-1,
|
output_msats=-1,
|
||||||
total_msats=cost_in_msats,
|
total_msats=cost_in_msats,
|
||||||
|
total_usd=usd_cost,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -210,6 +213,7 @@ async def calculate_cost( # todo: can be sync
|
|||||||
|
|
||||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||||
token_based_cost = math.ceil(input_msats + output_msats)
|
token_based_cost = math.ceil(input_msats + output_msats)
|
||||||
|
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Calculated token-based cost",
|
"Calculated token-based cost",
|
||||||
@@ -219,6 +223,7 @@ async def calculate_cost( # todo: can be sync
|
|||||||
"input_cost_msats": input_msats,
|
"input_cost_msats": input_msats,
|
||||||
"output_cost_msats": output_msats,
|
"output_cost_msats": output_msats,
|
||||||
"total_cost_msats": token_based_cost,
|
"total_cost_msats": token_based_cost,
|
||||||
|
"total_usd": total_usd,
|
||||||
"model": response_data.get("model", "unknown"),
|
"model": response_data.get("model", "unknown"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -228,4 +233,5 @@ async def calculate_cost( # todo: can be sync
|
|||||||
input_msats=int(input_msats),
|
input_msats=int(input_msats),
|
||||||
output_msats=int(output_msats),
|
output_msats=int(output_msats),
|
||||||
total_msats=token_based_cost,
|
total_msats=token_based_cost,
|
||||||
|
total_usd=total_usd,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif auth := headers.get("authorization", None):
|
elif auth := headers.get("authorization", None):
|
||||||
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
|
parts = auth.split()
|
||||||
|
cashu_token = parts[1] if len(parts) > 1 else ""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Using Authorization header token",
|
"Using Authorization header token",
|
||||||
extra={
|
extra={
|
||||||
@@ -169,9 +170,32 @@ async def calculate_discounted_max_cost(
|
|||||||
|
|
||||||
tol = settings.tolerance_percentage
|
tol = settings.tolerance_percentage
|
||||||
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
||||||
|
|
||||||
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
||||||
max_completion_allowed_sats = model_pricing.max_completion_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
|
adjusted = max_cost_for_model
|
||||||
|
|
||||||
if messages := body.get("messages"):
|
if messages := body.get("messages"):
|
||||||
|
|||||||
@@ -25,82 +25,6 @@ class LNURLError(Exception):
|
|||||||
"""LNURL related errors."""
|
"""LNURL related errors."""
|
||||||
|
|
||||||
|
|
||||||
def parse_lightning_invoice_amount(invoice: str, currency: str = "sat") -> int:
|
|
||||||
"""Parse Lightning invoice (BOLT-11) to extract amount in specified currency units.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
invoice: BOLT-11 Lightning invoice string
|
|
||||||
currency: Target currency unit ("sat" or "msat")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Amount in the specified currency unit
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
LNURLError: If invoice format is invalid or amount cannot be parsed
|
|
||||||
"""
|
|
||||||
invoice = invoice.lower().strip()
|
|
||||||
|
|
||||||
if not invoice.startswith("ln"):
|
|
||||||
raise LNURLError("Invalid Lightning invoice format")
|
|
||||||
|
|
||||||
# Find the network part (bc, tb, etc.)
|
|
||||||
network_start = 2
|
|
||||||
while network_start < len(invoice) and invoice[network_start] not in "0123456789":
|
|
||||||
network_start += 1
|
|
||||||
|
|
||||||
if network_start >= len(invoice):
|
|
||||||
raise LNURLError("Invalid Lightning invoice format")
|
|
||||||
|
|
||||||
# Parse amount and multiplier
|
|
||||||
amount_str = ""
|
|
||||||
multiplier = ""
|
|
||||||
i = network_start
|
|
||||||
|
|
||||||
# Extract numeric part
|
|
||||||
while i < len(invoice) and invoice[i].isdigit():
|
|
||||||
amount_str += invoice[i]
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
# Extract multiplier if present
|
|
||||||
if i < len(invoice) and invoice[i] in "munp":
|
|
||||||
multiplier = invoice[i]
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
# Check if we have the required "1" separator
|
|
||||||
if i >= len(invoice) or invoice[i] != "1":
|
|
||||||
raise LNURLError("Invalid Lightning invoice format")
|
|
||||||
|
|
||||||
if not amount_str:
|
|
||||||
raise LNURLError("Lightning invoice amount not specified")
|
|
||||||
|
|
||||||
# Convert to base units
|
|
||||||
try:
|
|
||||||
amount = int(amount_str)
|
|
||||||
except ValueError:
|
|
||||||
raise LNURLError("Invalid Lightning invoice amount")
|
|
||||||
|
|
||||||
# Apply multiplier to get millisatoshis
|
|
||||||
if multiplier == "m": # milli = 10^-3
|
|
||||||
amount_msat = amount * 100_000_000 # amount is in BTC * 10^-3
|
|
||||||
elif multiplier == "u": # micro = 10^-6
|
|
||||||
amount_msat = amount * 100_000 # amount is in BTC * 10^-6
|
|
||||||
elif multiplier == "n": # nano = 10^-9
|
|
||||||
amount_msat = amount * 100 # amount is in BTC * 10^-9
|
|
||||||
elif multiplier == "p": # pico = 10^-12
|
|
||||||
amount_msat = amount // 10 # amount is in BTC * 10^-12
|
|
||||||
else:
|
|
||||||
# No multiplier means the amount is in BTC
|
|
||||||
amount_msat = amount * 100_000_000_000 # Convert BTC to msat
|
|
||||||
|
|
||||||
# Convert to target currency unit
|
|
||||||
if currency == "msat":
|
|
||||||
return amount_msat
|
|
||||||
elif currency == "sat":
|
|
||||||
return amount_msat // 1000
|
|
||||||
else:
|
|
||||||
raise LNURLError(f"Unsupported currency for Lightning: {currency}")
|
|
||||||
|
|
||||||
|
|
||||||
async def decode_lnurl(lnurl: str) -> str:
|
async def decode_lnurl(lnurl: str) -> str:
|
||||||
"""Decode LNURL to get the actual URL.
|
"""Decode LNURL to get the actual URL.
|
||||||
|
|
||||||
@@ -291,9 +215,7 @@ async def raw_send_to_lnurl(
|
|||||||
lnurl_data["callback_url"], final_amount
|
lnurl_data["callback_url"], final_amount
|
||||||
)
|
)
|
||||||
|
|
||||||
melt_quote_resp = await wallet.melt_quote(
|
melt_quote_resp = await wallet.melt_quote(invoice=bolt11_invoice)
|
||||||
invoice=bolt11_invoice, amount_msat=final_amount
|
|
||||||
)
|
|
||||||
|
|
||||||
if amount:
|
if amount:
|
||||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||||
|
|||||||
@@ -143,14 +143,6 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def is_openrouter_upstream() -> bool:
|
|
||||||
try:
|
|
||||||
base = (settings.upstream_base_url or "").strip().rstrip("/")
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return base.lower() == "https://openrouter.ai/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_model(
|
def _row_to_model(
|
||||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||||
) -> Model:
|
) -> Model:
|
||||||
@@ -203,33 +195,11 @@ def _row_to_model(
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
|
|
||||||
return {
|
|
||||||
"id": model.id,
|
|
||||||
"name": model.name,
|
|
||||||
"created": model.created,
|
|
||||||
"description": model.description,
|
|
||||||
"context_length": model.context_length,
|
|
||||||
"architecture": json.dumps(model.architecture.dict()),
|
|
||||||
"pricing": json.dumps(model.pricing.dict()),
|
|
||||||
"sats_pricing": json.dumps(model.sats_pricing.dict())
|
|
||||||
if model.sats_pricing
|
|
||||||
else None,
|
|
||||||
"per_request_limits": json.dumps(model.per_request_limits)
|
|
||||||
if model.per_request_limits is not None
|
|
||||||
else None,
|
|
||||||
"top_provider": json.dumps(model.top_provider.dict())
|
|
||||||
if model.top_provider is not None
|
|
||||||
else None,
|
|
||||||
"enabled": model.enabled,
|
|
||||||
"upstream_provider_id": model.upstream_provider_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def list_models(
|
async def list_models(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
upstream_id: int,
|
upstream_id: int,
|
||||||
include_disabled: bool = False,
|
include_disabled: bool = False,
|
||||||
|
apply_fees: bool = True,
|
||||||
) -> list[Model]:
|
) -> list[Model]:
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
@@ -247,7 +217,7 @@ async def list_models(
|
|||||||
return [
|
return [
|
||||||
_row_to_model(
|
_row_to_model(
|
||||||
r,
|
r,
|
||||||
apply_provider_fee=True,
|
apply_provider_fee=apply_fees,
|
||||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||||
if r.upstream_provider_id in providers_by_id
|
if r.upstream_provider_id in providers_by_id
|
||||||
else 1.01,
|
else 1.01,
|
||||||
@@ -261,21 +231,6 @@ async def list_models(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def get_model_by_id(
|
|
||||||
model_id: str, provider_id: int, session: AsyncSession
|
|
||||||
) -> Model | None:
|
|
||||||
from ..core.db import UpstreamProviderRow
|
|
||||||
|
|
||||||
row = await session.get(ModelRow, (model_id, provider_id))
|
|
||||||
if not row or not row.enabled:
|
|
||||||
return None
|
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
|
||||||
if not provider or not provider.enabled:
|
|
||||||
return None
|
|
||||||
provider_fee = provider.provider_fee if provider else 1.01
|
|
||||||
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||||
"""Calculate max costs in USD based on model context/token limits.
|
"""Calculate max costs in USD based on model context/token limits.
|
||||||
|
|
||||||
|
|||||||
+201
-72
@@ -16,6 +16,8 @@ from .core.db import (
|
|||||||
create_session,
|
create_session,
|
||||||
get_session,
|
get_session,
|
||||||
)
|
)
|
||||||
|
from .core.exceptions import UpstreamError
|
||||||
|
from .core.settings import settings
|
||||||
from .payment.helpers import (
|
from .payment.helpers import (
|
||||||
calculate_discounted_max_cost,
|
calculate_discounted_max_cost,
|
||||||
check_token_balance,
|
check_token_balance,
|
||||||
@@ -31,7 +33,9 @@ proxy_router = APIRouter()
|
|||||||
|
|
||||||
_upstreams: list[BaseUpstreamProvider] = []
|
_upstreams: list[BaseUpstreamProvider] = []
|
||||||
_model_instances: dict[str, Model] = {} # All aliases -> Model
|
_model_instances: dict[str, Model] = {} # All aliases -> Model
|
||||||
_provider_map: dict[str, BaseUpstreamProvider] = {} # All aliases -> Provider
|
_provider_map: dict[
|
||||||
|
str, list[BaseUpstreamProvider]
|
||||||
|
] = {} # All aliases -> List[Provider]
|
||||||
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
|
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,8 +72,8 @@ def get_model_instance(model_id: str) -> Model | None:
|
|||||||
return _model_instances.get(model_id.lower())
|
return _model_instances.get(model_id.lower())
|
||||||
|
|
||||||
|
|
||||||
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
|
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
|
||||||
"""Get UpstreamProvider for model ID from global cache."""
|
"""Get UpstreamProvider list for model ID from global cache."""
|
||||||
return _provider_map.get(model_id.lower())
|
return _provider_map.get(model_id.lower())
|
||||||
|
|
||||||
|
|
||||||
@@ -154,8 +158,8 @@ async def proxy(
|
|||||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
upstream = get_provider_for_model(model_id)
|
upstreams = get_provider_for_model(model_id)
|
||||||
if not upstream:
|
if not upstreams:
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"invalid_model",
|
"invalid_model",
|
||||||
f"No provider found for model '{model_id}'",
|
f"No provider found for model '{model_id}'",
|
||||||
@@ -163,26 +167,52 @@ async def proxy(
|
|||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# todo figure out cost calculation since fallback provider is usually not the same price
|
||||||
|
# Use first provider for initial checks/cost calculation
|
||||||
|
# primary_upstream = upstreams[0]
|
||||||
|
|
||||||
_max_cost_for_model = await get_max_cost_for_model(
|
_max_cost_for_model = await get_max_cost_for_model(
|
||||||
model=model_id, session=session, model_obj=model_obj
|
model=model_id, session=session, model_obj=model_obj
|
||||||
)
|
)
|
||||||
max_cost_for_model = await calculate_discounted_max_cost(
|
max_cost_for_model = await calculate_discounted_max_cost(
|
||||||
_max_cost_for_model, request_body_dict, model_obj=model_obj
|
_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)
|
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||||
|
|
||||||
if x_cashu := headers.get("x-cashu", None):
|
if x_cashu := headers.get("x-cashu", None):
|
||||||
if is_responses_api:
|
last_error = None
|
||||||
return await upstream.handle_x_cashu_responses(
|
for i, upstream in enumerate(upstreams):
|
||||||
request, x_cashu, path, max_cost_for_model, model_obj
|
try:
|
||||||
)
|
if is_responses_api:
|
||||||
else:
|
return await upstream.handle_x_cashu_responses(
|
||||||
return await upstream.handle_x_cashu(
|
request, x_cashu, path, max_cost_for_model, model_obj
|
||||||
request, x_cashu, path, max_cost_for_model, model_obj
|
)
|
||||||
)
|
else:
|
||||||
|
return await upstream.handle_x_cashu(
|
||||||
|
request, x_cashu, path, max_cost_for_model, model_obj
|
||||||
|
)
|
||||||
|
except UpstreamError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
|
||||||
|
)
|
||||||
|
if i == len(upstreams) - 1:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
return create_error_response(
|
||||||
|
"upstream_error",
|
||||||
|
str(last_error) if last_error else "All upstreams failed",
|
||||||
|
502,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
elif auth := headers.get("authorization", None):
|
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:
|
else:
|
||||||
if request.method not in ["GET"]:
|
if request.method not in ["GET"]:
|
||||||
@@ -194,77 +224,174 @@ async def proxy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
||||||
headers = upstream.prepare_headers(dict(request.headers))
|
|
||||||
return await upstream.forward_get_request(request, path, headers)
|
last_error_response = None
|
||||||
|
for i, upstream in enumerate(upstreams):
|
||||||
|
try:
|
||||||
|
headers = upstream.prepare_headers(dict(request.headers))
|
||||||
|
response = await upstream.forward_get_request(request, path, headers)
|
||||||
|
|
||||||
|
if response.status_code in [502, 429] and i < len(upstreams) - 1:
|
||||||
|
error_message = ""
|
||||||
|
try:
|
||||||
|
if hasattr(response, "body"):
|
||||||
|
body_bytes = response.body
|
||||||
|
data = json.loads(body_bytes)
|
||||||
|
if "error" in data:
|
||||||
|
error_data = data["error"]
|
||||||
|
if isinstance(error_data, dict):
|
||||||
|
error_message = error_data.get("message", "")
|
||||||
|
elif isinstance(error_data, str):
|
||||||
|
error_message = error_data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await upstream.on_upstream_error_redirect(
|
||||||
|
response.status_code, error_message
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"Upstream {upstream.provider_type} returned {response.status_code} (GET), trying next provider",
|
||||||
|
extra={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"upstream": upstream.provider_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return response
|
||||||
|
except UpstreamError as e:
|
||||||
|
logger.warning(f"Upstream {upstream.provider_type} failed (GET): {e}")
|
||||||
|
if i == len(upstreams) - 1:
|
||||||
|
last_error_response = create_error_response(
|
||||||
|
"upstream_error", str(e), 502, request=request
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return last_error_response or create_error_response(
|
||||||
|
"upstream_error", "All upstreams failed", 502, request=request
|
||||||
|
)
|
||||||
|
|
||||||
if request_body_dict:
|
if request_body_dict:
|
||||||
await pay_for_request(key, max_cost_for_model, session)
|
await pay_for_request(key, max_cost_for_model, session)
|
||||||
|
|
||||||
headers = upstream.prepare_headers(dict(request.headers))
|
for i, upstream in enumerate(upstreams):
|
||||||
|
headers = upstream.prepare_headers(dict(request.headers))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if is_responses_api:
|
try:
|
||||||
response = await upstream.forward_responses_request(
|
if is_responses_api:
|
||||||
request,
|
response = await upstream.forward_responses_request(
|
||||||
path,
|
request,
|
||||||
headers,
|
path,
|
||||||
request_body,
|
headers,
|
||||||
key,
|
request_body,
|
||||||
max_cost_for_model,
|
key,
|
||||||
session,
|
max_cost_for_model,
|
||||||
model_obj,
|
session,
|
||||||
|
model_obj,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await upstream.forward_request(
|
||||||
|
request,
|
||||||
|
path,
|
||||||
|
headers,
|
||||||
|
request_body,
|
||||||
|
key,
|
||||||
|
max_cost_for_model,
|
||||||
|
session,
|
||||||
|
model_obj,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Upstream request failed, ensuring payment is reverted",
|
||||||
|
extra={
|
||||||
|
"error": str(e),
|
||||||
|
"error_type": type(e).__name__,
|
||||||
|
"path": path,
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"max_cost_for_model": max_cost_for_model,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
# Check if we should retry (502 Upstream Error or 429 Rate Limit)
|
||||||
|
should_retry = response.status_code in [502, 429, 400, 401, 403, 404]
|
||||||
|
if should_retry and i < len(upstreams) - 1:
|
||||||
|
error_message = ""
|
||||||
|
try:
|
||||||
|
if hasattr(response, "body"):
|
||||||
|
body_bytes = response.body
|
||||||
|
data = json.loads(body_bytes)
|
||||||
|
if "error" in data:
|
||||||
|
error_data = data["error"]
|
||||||
|
if isinstance(error_data, dict):
|
||||||
|
error_message = error_data.get("message", "")
|
||||||
|
elif isinstance(error_data, str):
|
||||||
|
error_message = error_data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await upstream.on_upstream_error_redirect(
|
||||||
|
response.status_code, error_message
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
|
||||||
|
extra={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"upstream": upstream.provider_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 4xx error (user error), or other non-retryable error, or last provider failed
|
||||||
|
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||||
|
logger.warning(
|
||||||
|
"Upstream request failed, revert payment",
|
||||||
|
extra={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"path": path,
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"key_balance": key.balance,
|
||||||
|
"max_cost_for_model": max_cost_for_model,
|
||||||
|
"upstream_headers": response.headers
|
||||||
|
if hasattr(response, "headers")
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except UpstreamError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Upstream {upstream.provider_type} failed: {e}",
|
||||||
|
extra={"retry": i < len(upstreams) - 1},
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
response = await upstream.forward_request(
|
|
||||||
request,
|
|
||||||
path,
|
|
||||||
headers,
|
|
||||||
request_body,
|
|
||||||
key,
|
|
||||||
max_cost_for_model,
|
|
||||||
session,
|
|
||||||
model_obj,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Upstream request failed, ensuring payment is reverted",
|
|
||||||
extra={
|
|
||||||
"error": str(e),
|
|
||||||
"error_type": type(e).__name__,
|
|
||||||
"path": path,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"max_cost_for_model": max_cost_for_model,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
|
||||||
raise
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
# If this was the last provider
|
||||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
if i == len(upstreams) - 1:
|
||||||
logger.warning(
|
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||||
"Upstream request failed, revert payment",
|
return create_error_response(
|
||||||
extra={
|
"upstream_error", str(e), 502, request=request
|
||||||
"status_code": response.status_code,
|
)
|
||||||
"path": path,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"key_balance": key.balance,
|
|
||||||
"max_cost_for_model": max_cost_for_model,
|
|
||||||
"upstream_headers": response.headers
|
|
||||||
if hasattr(response, "headers")
|
|
||||||
else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Return the mapped error response generated earlier rather than masking with 502
|
|
||||||
return response
|
|
||||||
|
|
||||||
return response
|
# Otherwise loop continues to next provider
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Should not be reached given logic above
|
||||||
|
return create_error_response(
|
||||||
|
"upstream_error", "All upstreams failed", 502, request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_bearer_token_key(
|
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:
|
) -> ApiKey:
|
||||||
"""Handle bearer token authentication proxy requests."""
|
"""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)
|
refund_address = headers.get("Refund-LNURL", None)
|
||||||
key_expiry_time = headers.get("Key-Expiry-Time", None)
|
key_expiry_time = headers.get("Key-Expiry-Time", None)
|
||||||
|
|
||||||
@@ -277,6 +404,7 @@ async def get_bearer_token_key(
|
|||||||
"bearer_key_preview": bearer_key[:20] + "..."
|
"bearer_key_preview": bearer_key[:20] + "..."
|
||||||
if len(bearer_key) > 20
|
if len(bearer_key) > 20
|
||||||
else bearer_key,
|
else bearer_key,
|
||||||
|
"min_cost": min_cost,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -315,6 +443,7 @@ async def get_bearer_token_key(
|
|||||||
session,
|
session,
|
||||||
refund_address,
|
refund_address,
|
||||||
key_expiry_time, # type: ignore
|
key_expiry_time, # type: ignore
|
||||||
|
min_cost=min_cost,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Bearer token validated successfully",
|
"Bearer token validated successfully",
|
||||||
|
|||||||
+287
-296
@@ -5,20 +5,18 @@ import json
|
|||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import TYPE_CHECKING, Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import BackgroundTasks, HTTPException, Request
|
from fastapi import BackgroundTasks, HTTPException, Request
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from pydantic import BaseModel
|
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, revert_pay_for_request
|
||||||
from ..core import get_logger
|
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 (
|
from ..payment.cost_calculation import (
|
||||||
CostData,
|
CostData,
|
||||||
CostDataError,
|
CostDataError,
|
||||||
@@ -31,6 +29,7 @@ from ..payment.models import (
|
|||||||
Pricing,
|
Pricing,
|
||||||
_calculate_usd_max_costs,
|
_calculate_usd_max_costs,
|
||||||
_update_model_sats_pricing,
|
_update_model_sats_pricing,
|
||||||
|
list_models,
|
||||||
)
|
)
|
||||||
from ..payment.price import sats_usd_price
|
from ..payment.price import sats_usd_price
|
||||||
from ..wallet import recieve_token, send_token
|
from ..wallet import recieve_token, send_token
|
||||||
@@ -340,6 +339,20 @@ class BaseUpstreamProvider:
|
|||||||
message = preview[:500]
|
message = preview[:500]
|
||||||
return message, upstream_code
|
return message, upstream_code
|
||||||
|
|
||||||
|
async def on_upstream_error_redirect(
|
||||||
|
self, status_code: int, error_message: str
|
||||||
|
) -> None:
|
||||||
|
"""Hook called when the proxy redirects to another provider due to an error.
|
||||||
|
|
||||||
|
Subclasses can implement this to perform actions like disabling the provider
|
||||||
|
if it's out of balance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_code: The HTTP status code returned by the upstream
|
||||||
|
error_message: The error message extracted from the upstream response
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
async def map_upstream_error_response(
|
async def map_upstream_error_response(
|
||||||
self, request: Request, path: str, upstream_response: httpx.Response
|
self, request: Request, path: str, upstream_response: httpx.Response
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -412,7 +425,11 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def handle_streaming_chat_completion(
|
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:
|
) -> StreamingResponse:
|
||||||
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
|
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
|
||||||
|
|
||||||
@@ -436,164 +453,120 @@ class BaseUpstreamProvider:
|
|||||||
async def stream_with_cost(
|
async def stream_with_cost(
|
||||||
max_cost_for_model: int,
|
max_cost_for_model: int,
|
||||||
) -> AsyncGenerator[bytes, None]:
|
) -> AsyncGenerator[bytes, None]:
|
||||||
stored_chunks: list[bytes] = []
|
|
||||||
usage_finalized: bool = False
|
usage_finalized: bool = False
|
||||||
last_model_seen: str | None = None
|
last_model_seen: str | None = None
|
||||||
|
usage_chunk_data: dict | None = None
|
||||||
|
done_seen: bool = False
|
||||||
|
|
||||||
async def finalize_without_usage() -> bytes | None:
|
async def finalize_db_only() -> None:
|
||||||
nonlocal usage_finalized
|
nonlocal usage_finalized
|
||||||
if usage_finalized:
|
if usage_finalized:
|
||||||
return None
|
return
|
||||||
async with create_session() as new_session:
|
async with create_session() as new_session:
|
||||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||||
if not fresh_key:
|
if not fresh_key:
|
||||||
logger.warning(
|
return
|
||||||
"Key not found when finalizing streaming payment",
|
|
||||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
fallback: dict = {
|
await adjust_payment_for_tokens(
|
||||||
"model": last_model_seen or "unknown",
|
fresh_key,
|
||||||
"usage": None,
|
{"model": last_model_seen or "unknown", "usage": None},
|
||||||
}
|
new_session,
|
||||||
cost_data = await adjust_payment_for_tokens(
|
max_cost_for_model,
|
||||||
fresh_key, fallback, new_session, max_cost_for_model
|
|
||||||
)
|
)
|
||||||
usage_finalized = True
|
usage_finalized = True
|
||||||
logger.info(
|
|
||||||
"Finalized streaming payment without explicit usage",
|
|
||||||
extra={
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"cost_data": cost_data,
|
|
||||||
"balance_after_adjustment": fresh_key.balance,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return f"data: {json.dumps({'cost': cost_data})}\n\n".encode()
|
|
||||||
except Exception as cost_error:
|
|
||||||
logger.error(
|
|
||||||
"Error finalizing payment without usage",
|
|
||||||
extra={
|
|
||||||
"error": str(cost_error),
|
|
||||||
"error_type": type(cost_error).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for chunk in response.aiter_bytes():
|
|
||||||
stored_chunks.append(chunk)
|
|
||||||
try:
|
|
||||||
for part in re.split(b"data: ", chunk):
|
|
||||||
if not part or part.strip() in (b"[DONE]", b""):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = json.loads(part)
|
|
||||||
if isinstance(obj, dict) and obj.get("model"):
|
|
||||||
last_model_seen = str(obj.get("model"))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
yield chunk
|
try:
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
# Split chunk into SSE events
|
||||||
|
parts = re.split(b"data: ", chunk)
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
|
||||||
logger.debug(
|
stripped_part = part.strip()
|
||||||
"Streaming completed, analyzing usage data",
|
if not stripped_part:
|
||||||
extra={
|
continue
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"chunks_count": len(stored_chunks),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for i in range(len(stored_chunks) - 1, -1, -1):
|
if stripped_part == b"[DONE]":
|
||||||
chunk = stored_chunks[i]
|
done_seen = True
|
||||||
if not chunk:
|
continue
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
events = re.split(b"data: ", chunk)
|
obj = json.loads(part)
|
||||||
for event_data in events:
|
if isinstance(obj, dict):
|
||||||
if not event_data or event_data.strip() in (b"[DONE]", b""):
|
if obj.get("model"):
|
||||||
continue
|
last_model_seen = str(obj.get("model"))
|
||||||
try:
|
|
||||||
data = json.loads(event_data)
|
if isinstance(obj.get("usage"), dict):
|
||||||
if isinstance(data, dict) and data.get("model"):
|
# Hold this chunk back to merge cost later
|
||||||
last_model_seen = str(data.get("model"))
|
usage_chunk_data = obj
|
||||||
if isinstance(data, dict) and isinstance(
|
continue
|
||||||
data.get("usage"), dict
|
except json.JSONDecodeError:
|
||||||
):
|
pass
|
||||||
async with create_session() as new_session:
|
|
||||||
fresh_key = await new_session.get(
|
prefix = (
|
||||||
key.__class__, key.hashed_key
|
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
|
||||||
)
|
|
||||||
if fresh_key:
|
|
||||||
try:
|
|
||||||
cost_data = (
|
|
||||||
await adjust_payment_for_tokens(
|
|
||||||
fresh_key,
|
|
||||||
data,
|
|
||||||
new_session,
|
|
||||||
max_cost_for_model,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
logger.info(
|
|
||||||
"Payment adjustment completed for streaming",
|
|
||||||
extra={
|
|
||||||
"key_hash": key.hashed_key[:8]
|
|
||||||
+ "...",
|
|
||||||
"cost_data": cost_data,
|
|
||||||
"model": last_model_seen,
|
|
||||||
"balance_after_adjustment": fresh_key.balance,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
yield f"data: {json.dumps({'cost': cost_data})}\n\n".encode()
|
|
||||||
except Exception as cost_error:
|
|
||||||
logger.error(
|
|
||||||
"Error adjusting payment for streaming tokens",
|
|
||||||
extra={
|
|
||||||
"error": str(cost_error),
|
|
||||||
"error_type": type(
|
|
||||||
cost_error
|
|
||||||
).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8]
|
|
||||||
+ "...",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Error processing streaming response chunk",
|
|
||||||
extra={
|
|
||||||
"error": str(e),
|
|
||||||
"error_type": type(e).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
yield prefix + part
|
||||||
|
|
||||||
|
# Stream finished, process usage if found
|
||||||
|
if usage_chunk_data:
|
||||||
|
async with create_session() as session:
|
||||||
|
fresh_key = await session.get(key.__class__, key.hashed_key)
|
||||||
|
if fresh_key:
|
||||||
|
try:
|
||||||
|
cost_data = await adjust_payment_for_tokens(
|
||||||
|
fresh_key,
|
||||||
|
usage_chunk_data,
|
||||||
|
session,
|
||||||
|
max_cost_for_model,
|
||||||
|
)
|
||||||
|
# Merge cost into usage
|
||||||
|
usage_chunk_data["usage"]["cost"] = cost_data.get(
|
||||||
|
"total_usd", 0.0
|
||||||
|
)
|
||||||
|
# Keep detailed cost in metadata
|
||||||
|
usage_chunk_data["metadata"] = usage_chunk_data.get(
|
||||||
|
"metadata", {}
|
||||||
|
)
|
||||||
|
usage_chunk_data["metadata"]["routstr"] = {
|
||||||
|
"cost": cost_data
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||||
|
usage_finalized = True
|
||||||
|
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()
|
||||||
|
|
||||||
if not usage_finalized:
|
if not usage_finalized:
|
||||||
maybe_cost_event = await finalize_without_usage()
|
await finalize_db_only()
|
||||||
if maybe_cost_event is not None:
|
|
||||||
yield maybe_cost_event
|
if done_seen:
|
||||||
|
yield b"data: [DONE]\n\n"
|
||||||
|
|
||||||
except Exception as stream_error:
|
except Exception as stream_error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Streaming interrupted; finalizing without usage",
|
"Streaming interrupted; finalizing in background",
|
||||||
extra={
|
extra={
|
||||||
"error": str(stream_error),
|
"error": str(stream_error),
|
||||||
"error_type": type(stream_error).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
if not usage_finalized:
|
if not usage_finalized:
|
||||||
await finalize_without_usage()
|
# 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
|
# Remove inaccurate encoding headers from upstream response
|
||||||
response_headers = dict(response.headers)
|
response_headers = dict(response.headers)
|
||||||
@@ -633,6 +606,7 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
content: bytes | None = None
|
||||||
try:
|
try:
|
||||||
content = await response.aread()
|
content = await response.aread()
|
||||||
response_json = json.loads(content)
|
response_json = json.loads(content)
|
||||||
@@ -649,6 +623,14 @@ class BaseUpstreamProvider:
|
|||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Merge cost into usage for OpenCode
|
||||||
|
if "usage" in response_json:
|
||||||
|
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
|
||||||
|
|
||||||
|
# Keep detailed cost
|
||||||
|
response_json["metadata"] = response_json.get("metadata", {})
|
||||||
|
response_json["metadata"]["routstr"] = {"cost": cost_data}
|
||||||
response_json["cost"] = cost_data
|
response_json["cost"] = cost_data
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -734,180 +716,135 @@ class BaseUpstreamProvider:
|
|||||||
async def stream_with_responses_cost(
|
async def stream_with_responses_cost(
|
||||||
max_cost_for_model: int,
|
max_cost_for_model: int,
|
||||||
) -> AsyncGenerator[bytes, None]:
|
) -> AsyncGenerator[bytes, None]:
|
||||||
stored_chunks: list[bytes] = []
|
|
||||||
usage_finalized: bool = False
|
usage_finalized: bool = False
|
||||||
last_model_seen: str | None = None
|
last_model_seen: str | None = None
|
||||||
reasoning_tokens: int = 0
|
reasoning_tokens: int = 0
|
||||||
|
usage_chunk_data: dict | None = None
|
||||||
|
done_seen: bool = False
|
||||||
|
|
||||||
async def finalize_without_usage() -> bytes | None:
|
async def finalize_db_only() -> None:
|
||||||
nonlocal usage_finalized
|
nonlocal usage_finalized
|
||||||
if usage_finalized:
|
if usage_finalized:
|
||||||
return None
|
return
|
||||||
async with create_session() as new_session:
|
async with create_session() as new_session:
|
||||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||||
if not fresh_key:
|
if not fresh_key:
|
||||||
logger.warning(
|
return
|
||||||
"Key not found when finalizing Responses API streaming payment",
|
|
||||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
fallback: dict = {
|
await adjust_payment_for_tokens(
|
||||||
"model": last_model_seen or "unknown",
|
fresh_key,
|
||||||
"usage": None,
|
{"model": last_model_seen or "unknown", "usage": None},
|
||||||
}
|
new_session,
|
||||||
cost_data = await adjust_payment_for_tokens(
|
max_cost_for_model,
|
||||||
fresh_key, fallback, new_session, max_cost_for_model
|
|
||||||
)
|
)
|
||||||
usage_finalized = True
|
usage_finalized = True
|
||||||
logger.info(
|
|
||||||
"Finalized Responses API streaming payment without explicit usage",
|
|
||||||
extra={
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"cost_data": cost_data,
|
|
||||||
"balance_after_adjustment": fresh_key.balance,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return f"data: {json.dumps({'cost': cost_data})}\\n\\n".encode()
|
|
||||||
except Exception as cost_error:
|
|
||||||
logger.error(
|
|
||||||
"Error finalizing Responses API payment without usage",
|
|
||||||
extra={
|
|
||||||
"error": str(cost_error),
|
|
||||||
"error_type": type(cost_error).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for chunk in response.aiter_bytes():
|
|
||||||
stored_chunks.append(chunk)
|
|
||||||
try:
|
|
||||||
for part in re.split(b"data: ", chunk):
|
|
||||||
if not part or part.strip() in (b"[DONE]", b""):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = json.loads(part)
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
if obj.get("model"):
|
|
||||||
last_model_seen = str(obj.get("model"))
|
|
||||||
|
|
||||||
# Track reasoning tokens for Responses API
|
|
||||||
if usage := obj.get("usage", {}):
|
|
||||||
if (
|
|
||||||
isinstance(usage, dict)
|
|
||||||
and "reasoning_tokens" in usage
|
|
||||||
):
|
|
||||||
reasoning_tokens += usage.get(
|
|
||||||
"reasoning_tokens", 0
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
yield chunk
|
try:
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
# Split chunk into SSE events
|
||||||
|
parts = re.split(b"data: ", chunk)
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
|
||||||
logger.debug(
|
stripped_part = part.strip()
|
||||||
"Responses API streaming completed, analyzing usage data",
|
if not stripped_part:
|
||||||
extra={
|
continue
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"chunks_count": len(stored_chunks),
|
|
||||||
"reasoning_tokens": reasoning_tokens,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process final usage data
|
if stripped_part == b"[DONE]":
|
||||||
for i in range(len(stored_chunks) - 1, -1, -1):
|
done_seen = True
|
||||||
chunk = stored_chunks[i]
|
continue
|
||||||
if not chunk:
|
|
||||||
continue
|
try:
|
||||||
try:
|
obj = json.loads(part)
|
||||||
events = re.split(b"data: ", chunk)
|
if isinstance(obj, dict):
|
||||||
for event_data in events:
|
if obj.get("model"):
|
||||||
if not event_data or event_data.strip() in (b"[DONE]", b""):
|
last_model_seen = str(obj.get("model"))
|
||||||
continue
|
|
||||||
try:
|
# Track reasoning tokens for Responses API
|
||||||
data = json.loads(event_data)
|
if usage := obj.get("usage", {}):
|
||||||
if isinstance(data, dict) and data.get("model"):
|
if (
|
||||||
last_model_seen = str(data.get("model"))
|
isinstance(usage, dict)
|
||||||
if isinstance(data, dict) and isinstance(
|
and "reasoning_tokens" in usage
|
||||||
data.get("usage"), dict
|
):
|
||||||
):
|
reasoning_tokens += usage.get(
|
||||||
# Include reasoning tokens in usage calculation
|
"reasoning_tokens", 0
|
||||||
async with create_session() as new_session:
|
|
||||||
fresh_key = await new_session.get(
|
|
||||||
key.__class__, key.hashed_key
|
|
||||||
)
|
)
|
||||||
if fresh_key:
|
|
||||||
try:
|
# Responses API usage is in response.completed/incomplete events
|
||||||
cost_data = (
|
chunk_type = obj.get("type", "")
|
||||||
await adjust_payment_for_tokens(
|
if chunk_type in (
|
||||||
fresh_key,
|
"response.completed",
|
||||||
data,
|
"response.incomplete",
|
||||||
new_session,
|
):
|
||||||
max_cost_for_model,
|
usage_chunk_data = obj
|
||||||
)
|
continue
|
||||||
)
|
except json.JSONDecodeError:
|
||||||
usage_finalized = True
|
pass
|
||||||
logger.info(
|
|
||||||
"Payment adjustment completed for Responses API streaming",
|
prefix = (
|
||||||
extra={
|
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
|
||||||
"key_hash": key.hashed_key[:8]
|
|
||||||
+ "...",
|
|
||||||
"cost_data": cost_data,
|
|
||||||
"model": last_model_seen,
|
|
||||||
"reasoning_tokens": reasoning_tokens,
|
|
||||||
"balance_after_adjustment": fresh_key.balance,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
yield f"data: {json.dumps({'cost': cost_data})}\\n\\n".encode()
|
|
||||||
except Exception as cost_error:
|
|
||||||
logger.error(
|
|
||||||
"Error adjusting payment for Responses API streaming tokens",
|
|
||||||
extra={
|
|
||||||
"error": str(cost_error),
|
|
||||||
"error_type": type(
|
|
||||||
cost_error
|
|
||||||
).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8]
|
|
||||||
+ "...",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Error processing Responses API streaming response chunk",
|
|
||||||
extra={
|
|
||||||
"error": str(e),
|
|
||||||
"error_type": type(e).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
yield prefix + part
|
||||||
|
|
||||||
|
# Stream finished, process usage if found
|
||||||
|
if usage_chunk_data:
|
||||||
|
async with create_session() as session:
|
||||||
|
fresh_key = await session.get(key.__class__, key.hashed_key)
|
||||||
|
if fresh_key:
|
||||||
|
try:
|
||||||
|
cost_data = await adjust_payment_for_tokens(
|
||||||
|
fresh_key,
|
||||||
|
usage_chunk_data,
|
||||||
|
session,
|
||||||
|
max_cost_for_model,
|
||||||
|
)
|
||||||
|
# Merge cost into usage chunk
|
||||||
|
if (
|
||||||
|
"response" in usage_chunk_data
|
||||||
|
and "usage" in usage_chunk_data["response"]
|
||||||
|
):
|
||||||
|
usage_chunk_data["response"]["usage"]["cost"] = (
|
||||||
|
cost_data.get("total_usd", 0.0)
|
||||||
|
)
|
||||||
|
elif "usage" in usage_chunk_data:
|
||||||
|
usage_chunk_data["usage"]["cost"] = cost_data.get(
|
||||||
|
"total_usd", 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep detailed cost in metadata
|
||||||
|
usage_chunk_data["metadata"] = usage_chunk_data.get(
|
||||||
|
"metadata", {}
|
||||||
|
)
|
||||||
|
usage_chunk_data["metadata"]["routstr"] = {
|
||||||
|
"cost": cost_data
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||||
|
usage_finalized = True
|
||||||
|
except Exception:
|
||||||
|
# Fallback: yield original usage chunk if adjustment fails
|
||||||
|
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||||
|
|
||||||
if not usage_finalized:
|
if not usage_finalized:
|
||||||
maybe_cost_event = await finalize_without_usage()
|
await finalize_db_only()
|
||||||
if maybe_cost_event is not None:
|
|
||||||
yield maybe_cost_event
|
if done_seen:
|
||||||
|
yield b"data: [DONE]\n\n"
|
||||||
|
|
||||||
except Exception as stream_error:
|
except Exception as stream_error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Responses API streaming interrupted; finalizing without usage",
|
"Responses API streaming interrupted; finalizing in background",
|
||||||
extra={
|
extra={
|
||||||
"error": str(stream_error),
|
"error": str(stream_error),
|
||||||
"error_type": type(stream_error).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
if not usage_finalized:
|
if not usage_finalized:
|
||||||
await finalize_without_usage()
|
await finalize_db_only()
|
||||||
|
|
||||||
# Remove inaccurate encoding headers from upstream response
|
# Remove inaccurate encoding headers from upstream response
|
||||||
response_headers = dict(response.headers)
|
response_headers = dict(response.headers)
|
||||||
@@ -947,6 +884,7 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
content: bytes | None = None
|
||||||
try:
|
try:
|
||||||
content = await response.aread()
|
content = await response.aread()
|
||||||
response_json = json.loads(content)
|
response_json = json.loads(content)
|
||||||
@@ -966,6 +904,14 @@ class BaseUpstreamProvider:
|
|||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Merge cost into usage for OpenCode
|
||||||
|
if "usage" in response_json:
|
||||||
|
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
|
||||||
|
|
||||||
|
# Keep detailed cost
|
||||||
|
response_json["metadata"] = response_json.get("metadata", {})
|
||||||
|
response_json["metadata"]["routstr"] = {"cost": cost_data}
|
||||||
response_json["cost"] = cost_data
|
response_json["cost"] = cost_data
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1148,6 +1094,14 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
if response.status_code >= 500:
|
||||||
|
await response.aclose()
|
||||||
|
await client.aclose()
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Upstream returned status {response.status_code}",
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mapped_error = await self.map_upstream_error_response(
|
mapped_error = await self.map_upstream_error_response(
|
||||||
request, path, response
|
request, path, response
|
||||||
@@ -1193,12 +1147,12 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if is_streaming and response.status_code == 200:
|
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 = BackgroundTasks()
|
||||||
background_tasks.add_task(response.aclose)
|
background_tasks.add_task(response.aclose)
|
||||||
background_tasks.add_task(client.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
|
result.background = background_tasks
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1238,6 +1192,9 @@ class BaseUpstreamProvider:
|
|||||||
background=background_tasks,
|
background=background_tasks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except UpstreamError:
|
||||||
|
raise
|
||||||
|
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
error_type = type(exc).__name__
|
error_type = type(exc).__name__
|
||||||
@@ -1267,9 +1224,7 @@ class BaseUpstreamProvider:
|
|||||||
else:
|
else:
|
||||||
error_message = f"Error connecting to upstream service: {error_type}"
|
error_message = f"Error connecting to upstream service: {error_type}"
|
||||||
|
|
||||||
return create_error_response(
|
raise UpstreamError(error_message, status_code=502)
|
||||||
"upstream_error", error_message, 502, request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
@@ -1384,6 +1339,14 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
if response.status_code >= 500:
|
||||||
|
await response.aclose()
|
||||||
|
await client.aclose()
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Upstream returned status {response.status_code}",
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mapped_error = await self.map_upstream_error_response(
|
mapped_error = await self.map_upstream_error_response(
|
||||||
request, path, response
|
request, path, response
|
||||||
@@ -1451,6 +1414,9 @@ class BaseUpstreamProvider:
|
|||||||
background=background_tasks,
|
background=background_tasks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except UpstreamError:
|
||||||
|
raise
|
||||||
|
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
error_type = type(exc).__name__
|
error_type = type(exc).__name__
|
||||||
@@ -1480,9 +1446,7 @@ class BaseUpstreamProvider:
|
|||||||
else:
|
else:
|
||||||
error_message = f"Error connecting to upstream service: {error_type}"
|
error_message = f"Error connecting to upstream service: {error_type}"
|
||||||
|
|
||||||
return create_error_response(
|
raise UpstreamError(error_message, status_code=502)
|
||||||
"upstream_error", error_message, 502, request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
@@ -3057,18 +3021,45 @@ class BaseUpstreamProvider:
|
|||||||
async def refresh_models_cache(self) -> None:
|
async def refresh_models_cache(self) -> None:
|
||||||
"""Refresh the in-memory models cache from upstream API."""
|
"""Refresh the in-memory models cache from upstream API."""
|
||||||
try:
|
try:
|
||||||
models = await self.fetch_models()
|
async with create_session() as session:
|
||||||
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
stmt = select(UpstreamProviderRow).where(
|
||||||
|
UpstreamProviderRow.base_url == self.base_url,
|
||||||
|
UpstreamProviderRow.api_key == self.api_key
|
||||||
|
)
|
||||||
|
result = await session.exec(stmt)
|
||||||
|
|
||||||
|
# .first() returns the object or None if not found
|
||||||
|
provider = result.first()
|
||||||
|
if not provider or not provider.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
try:
|
db_models = await list_models(
|
||||||
sats_to_usd = sats_usd_price()
|
session=session,
|
||||||
self._models_cache = [
|
upstream_id=provider.id,
|
||||||
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
|
include_disabled=False,
|
||||||
]
|
apply_fees=False,
|
||||||
except Exception:
|
)
|
||||||
self._models_cache = models_with_fees
|
db_model_ids: set[str] = {model.id for model in db_models}
|
||||||
|
models = await self.fetch_models()
|
||||||
|
model_ids = [model.id for model in models]
|
||||||
|
diff = set(db_model_ids) - set(model_ids)
|
||||||
|
|
||||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
for db_model_id in diff:
|
||||||
|
found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id))
|
||||||
|
models.append(found_db_model)
|
||||||
|
|
||||||
|
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
||||||
|
print([mode.id for mode in models_with_fees])
|
||||||
|
|
||||||
|
try:
|
||||||
|
sats_to_usd = sats_usd_price()
|
||||||
|
self._models_cache = [
|
||||||
|
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
self._models_cache = models_with_fees
|
||||||
|
|
||||||
|
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
+21
-14
@@ -236,7 +236,7 @@ async def _seed_providers_from_settings(
|
|||||||
from . import upstream_provider_classes
|
from . import upstream_provider_classes
|
||||||
|
|
||||||
providers_to_add: list[UpstreamProviderRow] = []
|
providers_to_add: list[UpstreamProviderRow] = []
|
||||||
seeded_base_urls: set[str] = set()
|
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
provider_classes_by_type = {
|
provider_classes_by_type = {
|
||||||
cls.provider_type: cls
|
cls.provider_type: cls
|
||||||
@@ -261,7 +261,8 @@ async def _seed_providers_from_settings(
|
|||||||
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == base_url
|
UpstreamProviderRow.base_url == base_url,
|
||||||
|
UpstreamProviderRow.api_key == api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
@@ -273,13 +274,15 @@ async def _seed_providers_from_settings(
|
|||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seeded_base_urls.add(base_url)
|
seeded_provider_keys.add((base_url, api_key))
|
||||||
|
|
||||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
||||||
if ollama_base_url:
|
if ollama_base_url:
|
||||||
|
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "")
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == ollama_base_url
|
UpstreamProviderRow.base_url == ollama_base_url,
|
||||||
|
UpstreamProviderRow.api_key == ollama_api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
@@ -287,18 +290,20 @@ async def _seed_providers_from_settings(
|
|||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
provider_type="ollama",
|
provider_type="ollama",
|
||||||
base_url=ollama_base_url,
|
base_url=ollama_base_url,
|
||||||
api_key=os.environ.get("OLLAMA_API_KEY", ""),
|
api_key=ollama_api_key,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seeded_base_urls.add(ollama_base_url)
|
seeded_provider_keys.add((ollama_base_url, ollama_api_key))
|
||||||
|
|
||||||
if settings.chat_completions_api_version and settings.upstream_base_url:
|
if settings.chat_completions_api_version and settings.upstream_base_url:
|
||||||
base_url = settings.upstream_base_url
|
base_url = settings.upstream_base_url
|
||||||
if base_url not in seeded_base_urls:
|
api_key = settings.upstream_api_key
|
||||||
|
if (base_url, api_key) not in seeded_provider_keys:
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == base_url
|
UpstreamProviderRow.base_url == base_url,
|
||||||
|
UpstreamProviderRow.api_key == api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
@@ -306,19 +311,21 @@ async def _seed_providers_from_settings(
|
|||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
provider_type="azure",
|
provider_type="azure",
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=settings.upstream_api_key,
|
api_key=api_key,
|
||||||
api_version=settings.chat_completions_api_version,
|
api_version=settings.chat_completions_api_version,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seeded_base_urls.add(base_url)
|
seeded_provider_keys.add((base_url, api_key))
|
||||||
|
|
||||||
if settings.upstream_base_url and settings.upstream_api_key:
|
if settings.upstream_base_url and settings.upstream_api_key:
|
||||||
base_url = settings.upstream_base_url
|
base_url = settings.upstream_base_url
|
||||||
if base_url not in seeded_base_urls:
|
api_key = settings.upstream_api_key
|
||||||
|
if (base_url, api_key) not in seeded_provider_keys:
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == base_url
|
UpstreamProviderRow.base_url == base_url,
|
||||||
|
UpstreamProviderRow.api_key == api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
@@ -326,11 +333,11 @@ async def _seed_providers_from_settings(
|
|||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
provider_type="custom",
|
provider_type="custom",
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=settings.upstream_api_key,
|
api_key=api_key,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seeded_base_urls.add(base_url)
|
seeded_provider_keys.add((base_url, api_key))
|
||||||
|
|
||||||
for provider in providers_to_add:
|
for provider in providers_to_add:
|
||||||
session.add(provider)
|
session.add(provider)
|
||||||
|
|||||||
@@ -196,6 +196,37 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def on_upstream_error_redirect(
|
||||||
|
self, status_code: int, error_message: str
|
||||||
|
) -> None:
|
||||||
|
if "insufficient balance" in error_message.lower():
|
||||||
|
logger.warning(
|
||||||
|
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
||||||
|
extra={"error": error_message},
|
||||||
|
)
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ..core.db import UpstreamProviderRow, create_session
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
statement = select(UpstreamProviderRow).where(
|
||||||
|
UpstreamProviderRow.base_url == self.base_url,
|
||||||
|
UpstreamProviderRow.api_key == self.api_key,
|
||||||
|
)
|
||||||
|
result = await session.exec(statement)
|
||||||
|
provider = result.first()
|
||||||
|
|
||||||
|
if provider:
|
||||||
|
provider.enabled = False
|
||||||
|
session.add(provider)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Trigger re-initialization of providers
|
||||||
|
# Import here to avoid circular dependency
|
||||||
|
from ..proxy import reinitialize_upstreams
|
||||||
|
|
||||||
|
await reinitialize_upstreams()
|
||||||
|
|
||||||
async def create_account(self) -> dict[str, object]:
|
async def create_account(self) -> dict[str, object]:
|
||||||
"""Create a new PPQ.AI account.
|
"""Create a new PPQ.AI account.
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -319,6 +319,7 @@ async def periodic_payout() -> None:
|
|||||||
wallet, mint_url, unit, not_reserved=True
|
wallet, mint_url, unit, not_reserved=True
|
||||||
)
|
)
|
||||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||||
|
await asyncio.sleep(5)
|
||||||
user_balance = await db.balances_for_mint_and_unit(
|
user_balance = await db.balances_for_mint_and_unit(
|
||||||
session, mint_url, unit
|
session, mint_url, unit
|
||||||
)
|
)
|
||||||
@@ -344,8 +345,6 @@ async def periodic_payout() -> None:
|
|||||||
"amount_received": amount_received,
|
"amount_received": amount_received,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error sending payout: {type(e).__name__}",
|
f"Error sending payout: {type(e).__name__}",
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.auth import adjust_payment_for_tokens, pay_for_request
|
||||||
|
from routstr.balance import ChildKeyRequest, create_child_key
|
||||||
|
from routstr.core.db import ApiKey
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_child_key_flow(integration_session: AsyncSession) -> None:
|
||||||
|
# 1. Create a parent key with balance
|
||||||
|
parent_raw = "parent_test_key_" + secrets.token_hex(4)
|
||||||
|
parent_key = ApiKey(
|
||||||
|
hashed_key=parent_raw,
|
||||||
|
balance=10000, # 10 sats
|
||||||
|
)
|
||||||
|
integration_session.add(parent_key)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(parent_key)
|
||||||
|
|
||||||
|
# Mock settings
|
||||||
|
settings.child_key_cost = 1000 # 1 sat
|
||||||
|
|
||||||
|
# 2. Call create_child_key
|
||||||
|
result = await create_child_key(
|
||||||
|
ChildKeyRequest(count=1), parent_key, integration_session
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "api_keys" in result
|
||||||
|
assert result["cost_msats"] == 1000
|
||||||
|
assert result["parent_balance"] == 9000
|
||||||
|
|
||||||
|
child_key_raw = result["api_keys"][0][3:] # remove sk-
|
||||||
|
|
||||||
|
# 3. Verify child key exists in DB
|
||||||
|
child_key_db = await integration_session.get(ApiKey, child_key_raw)
|
||||||
|
assert child_key_db is not None
|
||||||
|
assert child_key_db.parent_key_hash == parent_key.hashed_key
|
||||||
|
assert child_key_db.balance == 0
|
||||||
|
|
||||||
|
# 4. Test payment with child key
|
||||||
|
cost = 500
|
||||||
|
await pay_for_request(child_key_db, cost, integration_session)
|
||||||
|
|
||||||
|
# Refresh keys
|
||||||
|
await integration_session.refresh(parent_key)
|
||||||
|
await integration_session.refresh(child_key_db)
|
||||||
|
|
||||||
|
# Parent should be charged
|
||||||
|
assert parent_key.reserved_balance == 500
|
||||||
|
assert parent_key.total_requests == 1
|
||||||
|
|
||||||
|
# Child should have total_requests incremented
|
||||||
|
assert child_key_db.total_requests == 1
|
||||||
|
|
||||||
|
# 5. Test adjustment
|
||||||
|
response_data = {"model": "test-model", "usage": {"total_tokens": 10}}
|
||||||
|
|
||||||
|
# Mock calculate_cost
|
||||||
|
import routstr.auth
|
||||||
|
from routstr.payment.cost_calculation import CostData
|
||||||
|
|
||||||
|
async def mock_calculate_cost(*args: Any, **kwargs: Any) -> CostData:
|
||||||
|
return CostData(
|
||||||
|
base_msats=0, input_msats=200, output_msats=200, total_msats=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patch calculate_cost
|
||||||
|
original_calculate_cost = routstr.auth.calculate_cost
|
||||||
|
routstr.auth.calculate_cost = mock_calculate_cost
|
||||||
|
|
||||||
|
try:
|
||||||
|
adjustment = await adjust_payment_for_tokens(
|
||||||
|
child_key_db, response_data, integration_session, 500
|
||||||
|
)
|
||||||
|
assert adjustment["total_msats"] == 400
|
||||||
|
|
||||||
|
# Refresh keys
|
||||||
|
await integration_session.refresh(parent_key)
|
||||||
|
await integration_session.refresh(child_key_db)
|
||||||
|
|
||||||
|
# Parent should have updated balance and total_spent
|
||||||
|
assert parent_key.reserved_balance == 0
|
||||||
|
assert parent_key.balance == 9000 - 400
|
||||||
|
assert (
|
||||||
|
parent_key.total_spent == 1400
|
||||||
|
) # 1000 for child key creation + 400 for request
|
||||||
|
|
||||||
|
# Child should also have total_spent updated
|
||||||
|
assert child_key_db.total_spent == 400
|
||||||
|
|
||||||
|
finally:
|
||||||
|
routstr.auth.calculate_cost = original_calculate_cost
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_child_key_insufficient_balance(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
parent_key = ApiKey(
|
||||||
|
hashed_key="poor_parent_" + secrets.token_hex(4),
|
||||||
|
balance=500,
|
||||||
|
)
|
||||||
|
integration_session.add(parent_key)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(parent_key)
|
||||||
|
|
||||||
|
settings.child_key_cost = 1000
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await create_child_key(
|
||||||
|
ChildKeyRequest(count=1), parent_key, integration_session
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_child_key_cannot_create_child(integration_session: AsyncSession) -> None:
|
||||||
|
parent_key = ApiKey(
|
||||||
|
hashed_key="parent_" + secrets.token_hex(4),
|
||||||
|
balance=10000,
|
||||||
|
)
|
||||||
|
child_key = ApiKey(
|
||||||
|
hashed_key="child_" + secrets.token_hex(4),
|
||||||
|
balance=0,
|
||||||
|
parent_key_hash=parent_key.hashed_key,
|
||||||
|
)
|
||||||
|
integration_session.add(parent_key)
|
||||||
|
integration_session.add(child_key)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(child_key)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await create_child_key(ChildKeyRequest(count=1), child_key, integration_session)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "Cannot create a child key for another child key" in str(exc.value.detail)
|
||||||
@@ -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 /"""
|
"""Test GET /admin/ endpoint redirects to /"""
|
||||||
await db_snapshot.capture()
|
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.status_code == 403
|
||||||
assert response.headers.get("location") == "/"
|
|
||||||
|
|
||||||
diff = await db_snapshot.diff()
|
diff = await db_snapshot.diff()
|
||||||
assert len(diff["api_keys"]["added"]) == 0
|
assert len(diff["api_keys"]["added"]) == 0
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.auth import pay_for_request
|
||||||
|
from routstr.core.db import ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_key_validity_date(integration_session: AsyncSession) -> None:
|
||||||
|
# 1. Create a key that is expired
|
||||||
|
expired_time = int(time.time()) - 3600
|
||||||
|
key = ApiKey(hashed_key="expired_key", balance=1000, validity_date=expired_time)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# 2. Try to pay for a request - should fail
|
||||||
|
with pytest.raises(Exception) as excinfo:
|
||||||
|
await pay_for_request(key, 100, integration_session)
|
||||||
|
assert "expired" in str(excinfo.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_key_balance_limit(integration_session: AsyncSession) -> None:
|
||||||
|
# 1. Create a key with a balance limit
|
||||||
|
key = ApiKey(
|
||||||
|
hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450
|
||||||
|
)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# 2. Try to pay for a request that exceeds the limit
|
||||||
|
with pytest.raises(Exception) as excinfo:
|
||||||
|
await pay_for_request(key, 100, integration_session)
|
||||||
|
assert "limit exceeded" in str(excinfo.value).lower()
|
||||||
|
|
||||||
|
# 3. Try to pay for a request that fits
|
||||||
|
await pay_for_request(key, 50, integration_session)
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
# Note: total_spent is updated in adjust_payment_for_tokens,
|
||||||
|
# but pay_for_request checks it.
|
||||||
|
# In our current logic, pay_for_request checks (total_spent + cost) > balance_limit.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None:
|
||||||
|
# 1. Create a key with a daily reset policy and old reset date
|
||||||
|
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
|
||||||
|
key = ApiKey(
|
||||||
|
hashed_key="daily_reset_key",
|
||||||
|
balance=10000,
|
||||||
|
balance_limit=1000,
|
||||||
|
balance_limit_reset="daily",
|
||||||
|
balance_limit_reset_date=yesterday,
|
||||||
|
total_spent=900,
|
||||||
|
)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# 2. Pay for a request - should trigger reset first because it's a new day
|
||||||
|
# Request is 200, total_spent is 900. 900+200 > 1000,
|
||||||
|
# but reset should happen making total_spent 0, then 0+200 < 1000.
|
||||||
|
await pay_for_request(key, 200, integration_session)
|
||||||
|
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.total_spent == 0 # Reset in pay_for_request happens before charging
|
||||||
|
# Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests,
|
||||||
|
# but total_spent is updated in adjust_payment_for_tokens.
|
||||||
|
# However, the reset logic sets total_spent to 0.
|
||||||
|
assert key.balance_limit_reset_date is not None
|
||||||
|
assert key.balance_limit_reset_date > yesterday
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None:
|
||||||
|
# 1. Create multiple keys needing reset
|
||||||
|
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
|
||||||
|
key1 = ApiKey(
|
||||||
|
hashed_key="job_reset_key_1",
|
||||||
|
balance=1000,
|
||||||
|
balance_limit=1000,
|
||||||
|
balance_limit_reset="daily",
|
||||||
|
balance_limit_reset_date=yesterday,
|
||||||
|
total_spent=500,
|
||||||
|
)
|
||||||
|
key2 = ApiKey(
|
||||||
|
hashed_key="job_reset_key_2",
|
||||||
|
balance=1000,
|
||||||
|
balance_limit=1000,
|
||||||
|
balance_limit_reset="daily",
|
||||||
|
balance_limit_reset_date=yesterday,
|
||||||
|
total_spent=800,
|
||||||
|
)
|
||||||
|
integration_session.add(key1)
|
||||||
|
integration_session.add(key2)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# 2. Run the periodic reset logic manually (mocking the background task loop)
|
||||||
|
# We can't easily run the actual loop because it has a sleep,
|
||||||
|
# but we can test the logic inside.
|
||||||
|
|
||||||
|
# Implementation of periodic_key_reset logic for testing:
|
||||||
|
stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) # noqa: E711
|
||||||
|
keys = (await integration_session.exec(stmt)).all()
|
||||||
|
now = int(time.time())
|
||||||
|
for k in keys:
|
||||||
|
if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]:
|
||||||
|
k.total_spent = 0
|
||||||
|
k.balance_limit_reset_date = now
|
||||||
|
integration_session.add(k)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# 3. Verify resets
|
||||||
|
await integration_session.refresh(key1)
|
||||||
|
await integration_session.refresh(key2)
|
||||||
|
assert key1.total_spent == 0
|
||||||
|
assert key2.total_spent == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
|
||||||
|
# This requires mocking the router call or testing the logic in balance.py
|
||||||
|
from routstr.balance import ApiKey
|
||||||
|
|
||||||
|
key = ApiKey(hashed_key="refund_test_key", balance=1000, reserved_balance=100)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# Logic from refund_wallet_endpoint:
|
||||||
|
key.balance = 0
|
||||||
|
key.reserved_balance = 0
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# Verify key still exists
|
||||||
|
fetched_key = await integration_session.get(ApiKey, "refund_test_key")
|
||||||
|
assert fetched_key is not None
|
||||||
|
assert fetched_key.balance == 0
|
||||||
|
assert fetched_key.reserved_balance == 0
|
||||||
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from routstr.discovery import _PROVIDERS_CACHE
|
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
||||||
|
|
||||||
from .utils import ResponseValidator
|
from .utils import ResponseValidator
|
||||||
|
|
||||||
@@ -71,9 +71,10 @@ async def test_providers_endpoint_default_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
with patch(
|
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
|
# Configure mock to return appropriate responses
|
||||||
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
|
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
|
||||||
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
|
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
|
||||||
@@ -135,9 +136,10 @@ async def test_providers_endpoint_with_include_json(
|
|||||||
}
|
}
|
||||||
|
|
||||||
with patch(
|
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_fetch.return_value = {
|
||||||
"status_code": 200,
|
"status_code": 200,
|
||||||
"json": mock_provider_response,
|
"json": mock_provider_response,
|
||||||
@@ -209,9 +211,10 @@ async def test_providers_data_structure_validation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
with patch(
|
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
|
mock_fetch.return_value = mock_health_response
|
||||||
|
|
||||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||||
@@ -256,7 +259,8 @@ async def test_providers_endpoint_no_providers_found(
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
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/")
|
response = await integration_client.get("/v1/providers/")
|
||||||
|
|
||||||
@@ -317,10 +321,11 @@ async def test_providers_endpoint_offline_providers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
with patch(
|
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(
|
with patch(
|
||||||
"routstr.discovery.fetch_provider_health",
|
"routstr.nostr.discovery.fetch_provider_health",
|
||||||
side_effect=mock_fetch_provider_health,
|
side_effect=mock_fetch_provider_health,
|
||||||
):
|
):
|
||||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||||
@@ -386,9 +391,10 @@ async def test_providers_endpoint_duplicate_urls(
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
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_fetch.return_value = {
|
||||||
"status_code": 200,
|
"status_code": 200,
|
||||||
"endpoint": "root",
|
"endpoint": "root",
|
||||||
@@ -425,7 +431,8 @@ async def test_providers_endpoint_nostr_relay_failures(
|
|||||||
raise Exception("Connection to relay failed")
|
raise Exception("Connection to relay failed")
|
||||||
|
|
||||||
with patch(
|
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/")
|
response = await integration_client.get("/v1/providers/")
|
||||||
|
|
||||||
@@ -463,9 +470,10 @@ async def test_providers_endpoint_malformed_urls(
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
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"}}
|
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||||
|
|
||||||
response = await integration_client.get("/v1/providers/")
|
response = await integration_client.get("/v1/providers/")
|
||||||
@@ -495,9 +503,10 @@ async def test_providers_endpoint_response_format(
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
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"}}
|
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||||
|
|
||||||
# Test default format
|
# Test default format
|
||||||
@@ -545,9 +554,10 @@ async def test_providers_endpoint_concurrent_requests(
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
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"}}
|
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||||
|
|
||||||
# Create concurrent requests
|
# Create concurrent requests
|
||||||
@@ -587,9 +597,10 @@ async def test_providers_endpoint_parameter_validation(
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
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"}}
|
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||||
|
|
||||||
# Test various parameter values
|
# Test various parameter values
|
||||||
@@ -639,9 +650,10 @@ async def test_no_database_changes_during_provider_operations(
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
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"}}
|
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||||
|
|
||||||
# Make multiple requests with different parameters
|
# Make multiple requests with different parameters
|
||||||
|
|||||||
@@ -65,9 +65,10 @@ async def test_full_balance_refund_returns_cashu_token(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
pytest.fail(f"Invalid Cashu token format: {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/")
|
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 refund token has been validated above by decoding it
|
||||||
# The API key deletion has been verified by the 401 response
|
# 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")
|
response = await authenticated_client.post("/v1/wallet/refund")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify key is deleted after refund
|
# Refresh the key to get the updated balance from the database
|
||||||
result = await integration_session.execute(
|
await integration_session.refresh(key_before)
|
||||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
assert result.scalar_one_or_none() is None
|
|
||||||
|
|
||||||
# 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))
|
result = await integration_session.execute(select(ApiKey))
|
||||||
remaining_keys = result.scalars().all()
|
remaining_keys = result.scalars().all()
|
||||||
# Should have no keys left (assuming clean test environment)
|
assert len(remaining_keys) == 1
|
||||||
assert len(remaining_keys) == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -388,9 +388,10 @@ async def test_refund_during_active_usage(
|
|||||||
# Refund should succeed
|
# Refund should succeed
|
||||||
assert refund_response.status_code == 200
|
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/")
|
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
|
@pytest.mark.integration
|
||||||
@@ -535,6 +536,3 @@ async def test_refund_with_expired_key(
|
|||||||
# Should still allow manual refund
|
# Should still allow manual refund
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["recipient"] == "expired@ln.address"
|
assert response.json()["recipient"] == "expired@ln.address"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ os.environ["UPSTREAM_API_KEY"] = "test"
|
|||||||
from routstr.algorithm import ( # noqa: E402
|
from routstr.algorithm import ( # noqa: E402
|
||||||
calculate_model_cost_score,
|
calculate_model_cost_score,
|
||||||
get_provider_penalty,
|
get_provider_penalty,
|
||||||
should_prefer_model,
|
|
||||||
)
|
)
|
||||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||||
|
|
||||||
@@ -100,100 +99,3 @@ def test_get_provider_penalty_openrouter() -> None:
|
|||||||
provider = create_test_provider("openrouter", "https://openrouter.ai/api/v1")
|
provider = create_test_provider("openrouter", "https://openrouter.ai/api/v1")
|
||||||
penalty = get_provider_penalty(provider)
|
penalty = get_provider_penalty(provider)
|
||||||
assert penalty == 1.001
|
assert penalty == 1.001
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_cheaper_wins() -> None:
|
|
||||||
"""Test that cheaper model is preferred."""
|
|
||||||
cheap_model = create_test_model("cheap", prompt_price=0.001, completion_price=0.002)
|
|
||||||
expensive_model = create_test_model(
|
|
||||||
"expensive", prompt_price=0.03, completion_price=0.06
|
|
||||||
)
|
|
||||||
|
|
||||||
provider1 = create_test_provider("provider1")
|
|
||||||
provider2 = create_test_provider("provider2")
|
|
||||||
|
|
||||||
# Cheaper model should win
|
|
||||||
assert should_prefer_model(
|
|
||||||
cheap_model, provider1, expensive_model, provider2, "test-alias"
|
|
||||||
)
|
|
||||||
|
|
||||||
# More expensive model should not win
|
|
||||||
assert not should_prefer_model(
|
|
||||||
expensive_model, provider2, cheap_model, provider1, "test-alias"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_exact_match_wins() -> None:
|
|
||||||
"""Test that exact alias match beats cheaper price."""
|
|
||||||
# Make model IDs match the alias differently
|
|
||||||
exact_match = create_test_model(
|
|
||||||
"test-model", prompt_price=0.03, completion_price=0.06
|
|
||||||
)
|
|
||||||
no_match = create_test_model(
|
|
||||||
"other-model", prompt_price=0.001, completion_price=0.002
|
|
||||||
)
|
|
||||||
|
|
||||||
provider1 = create_test_provider("provider1")
|
|
||||||
provider2 = create_test_provider("provider2")
|
|
||||||
|
|
||||||
# Exact match should win even though it's more expensive
|
|
||||||
assert should_prefer_model(
|
|
||||||
exact_match, provider1, no_match, provider2, "test-model"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_openrouter_slight_penalty() -> None:
|
|
||||||
"""Test that OpenRouter has slight penalty compared to other providers."""
|
|
||||||
model1 = create_test_model("model1", prompt_price=0.001, completion_price=0.002)
|
|
||||||
model2 = create_test_model("model2", prompt_price=0.001, completion_price=0.002)
|
|
||||||
|
|
||||||
regular_provider = create_test_provider("regular", "http://provider.com")
|
|
||||||
openrouter_provider = create_test_provider(
|
|
||||||
"openrouter", "https://openrouter.ai/api/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Regular provider should be preferred over OpenRouter at same cost
|
|
||||||
assert should_prefer_model(
|
|
||||||
model1, regular_provider, model2, openrouter_provider, "test-alias"
|
|
||||||
)
|
|
||||||
|
|
||||||
# OpenRouter should not replace regular provider at same cost
|
|
||||||
assert not should_prefer_model(
|
|
||||||
model2, openrouter_provider, model1, regular_provider, "test-alias"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_openrouter_can_win_if_cheaper() -> None:
|
|
||||||
"""Test that OpenRouter can still win if significantly cheaper."""
|
|
||||||
cheap_model = create_test_model(
|
|
||||||
"cheap", prompt_price=0.0001, completion_price=0.0002
|
|
||||||
)
|
|
||||||
expensive_model = create_test_model(
|
|
||||||
"expensive", prompt_price=0.03, completion_price=0.06
|
|
||||||
)
|
|
||||||
|
|
||||||
regular_provider = create_test_provider("regular", "http://provider.com")
|
|
||||||
openrouter_provider = create_test_provider(
|
|
||||||
"openrouter", "https://openrouter.ai/api/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
# OpenRouter should win if it's much cheaper (even with penalty)
|
|
||||||
assert should_prefer_model(
|
|
||||||
cheap_model,
|
|
||||||
openrouter_provider,
|
|
||||||
expensive_model,
|
|
||||||
regular_provider,
|
|
||||||
"test-alias",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_same_cost_first_wins() -> None:
|
|
||||||
"""Test that when costs are identical, current model is kept."""
|
|
||||||
model1 = create_test_model("model1", prompt_price=0.001, completion_price=0.002)
|
|
||||||
model2 = create_test_model("model2", prompt_price=0.001, completion_price=0.002)
|
|
||||||
|
|
||||||
provider1 = create_test_provider("provider1")
|
|
||||||
provider2 = create_test_provider("provider2")
|
|
||||||
|
|
||||||
# When costs are equal, should not replace
|
|
||||||
assert not should_prefer_model(model2, provider2, model1, provider1, "test-alias")
|
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
"""Unit tests for model row payload conversion.
|
|
||||||
|
|
||||||
This module tests that _model_to_row_payload correctly serializes model data
|
|
||||||
for database storage. Pricing is stored as-is without fee application.
|
|
||||||
Fees are now applied per-provider when reading from the database.
|
|
||||||
|
|
||||||
Key behaviors tested:
|
|
||||||
1. Pricing is stored as-is without fee application
|
|
||||||
2. All model fields are correctly serialized to JSON
|
|
||||||
3. Optional fields are handled correctly (None values)
|
|
||||||
4. Pricing structure is preserved
|
|
||||||
5. Original model objects are not mutated
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Set required env vars before importing
|
|
||||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
|
||||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
|
||||||
|
|
||||||
from routstr.payment.models import ( # noqa: E402
|
|
||||||
Architecture,
|
|
||||||
Model,
|
|
||||||
Pricing,
|
|
||||||
_model_to_row_payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def base_architecture() -> Architecture:
|
|
||||||
"""Provide standard architecture for test models."""
|
|
||||||
return Architecture(
|
|
||||||
modality="text",
|
|
||||||
input_modalities=["text"],
|
|
||||||
output_modalities=["text"],
|
|
||||||
tokenizer="gpt",
|
|
||||||
instruct_type="chat",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def standard_pricing() -> Pricing:
|
|
||||||
"""Provide standard USD pricing with known values for testing."""
|
|
||||||
return Pricing(
|
|
||||||
prompt=0.001,
|
|
||||||
completion=0.002,
|
|
||||||
request=0.01,
|
|
||||||
image=0.05,
|
|
||||||
web_search=0.03,
|
|
||||||
internal_reasoning=0.015,
|
|
||||||
max_prompt_cost=10.0,
|
|
||||||
max_completion_cost=20.0,
|
|
||||||
max_cost=30.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def standard_model(base_architecture: Architecture, standard_pricing: Pricing) -> Model:
|
|
||||||
"""Create a standard test model with known pricing."""
|
|
||||||
return Model(
|
|
||||||
id="test-model-standard",
|
|
||||||
name="Test Model Standard",
|
|
||||||
created=1234567890,
|
|
||||||
description="A standard test model",
|
|
||||||
context_length=8192,
|
|
||||||
architecture=base_architecture,
|
|
||||||
pricing=standard_pricing,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pricing_stored_without_fees(standard_model: Model) -> None:
|
|
||||||
"""Verify pricing is stored as-is without any fee application."""
|
|
||||||
payload = _model_to_row_payload(standard_model)
|
|
||||||
pricing_str = payload["pricing"]
|
|
||||||
assert isinstance(pricing_str, str)
|
|
||||||
pricing = json.loads(pricing_str)
|
|
||||||
|
|
||||||
assert pricing["prompt"] == pytest.approx(0.001, rel=1e-9)
|
|
||||||
assert pricing["completion"] == pytest.approx(0.002, rel=1e-9)
|
|
||||||
assert pricing["request"] == pytest.approx(0.01, rel=1e-9)
|
|
||||||
assert pricing["image"] == pytest.approx(0.05, rel=1e-9)
|
|
||||||
assert pricing["web_search"] == pytest.approx(0.03, rel=1e-9)
|
|
||||||
assert pricing["internal_reasoning"] == pytest.approx(0.015, rel=1e-9)
|
|
||||||
assert pricing["max_prompt_cost"] == pytest.approx(10.0, rel=1e-9)
|
|
||||||
assert pricing["max_completion_cost"] == pytest.approx(20.0, rel=1e-9)
|
|
||||||
assert pricing["max_cost"] == pytest.approx(30.0, rel=1e-9)
|
|
||||||
|
|
||||||
|
|
||||||
def test_zero_value_pricing_fields(base_architecture: Architecture) -> None:
|
|
||||||
"""Verify that zero-value pricing fields are stored correctly."""
|
|
||||||
zero_pricing = Pricing(
|
|
||||||
prompt=0.0,
|
|
||||||
completion=0.0,
|
|
||||||
request=0.0,
|
|
||||||
image=0.0,
|
|
||||||
web_search=0.0,
|
|
||||||
internal_reasoning=0.0,
|
|
||||||
max_prompt_cost=0.0,
|
|
||||||
max_completion_cost=0.0,
|
|
||||||
max_cost=0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
model = Model(
|
|
||||||
id="test-model-zero",
|
|
||||||
name="Test Model Zero",
|
|
||||||
created=1234567890,
|
|
||||||
description="A model with zero pricing",
|
|
||||||
context_length=8192,
|
|
||||||
architecture=base_architecture,
|
|
||||||
pricing=zero_pricing,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = _model_to_row_payload(model)
|
|
||||||
pricing_str = payload["pricing"]
|
|
||||||
assert isinstance(pricing_str, str)
|
|
||||||
pricing = json.loads(pricing_str)
|
|
||||||
|
|
||||||
assert pricing["prompt"] == pytest.approx(0.0, rel=1e-9)
|
|
||||||
assert pricing["completion"] == pytest.approx(0.0, rel=1e-9)
|
|
||||||
assert pricing["request"] == pytest.approx(0.0, rel=1e-9)
|
|
||||||
|
|
||||||
|
|
||||||
def test_payload_structure_unchanged(standard_model: Model) -> None:
|
|
||||||
"""Verify that payload structure matches expectations."""
|
|
||||||
payload = _model_to_row_payload(standard_model)
|
|
||||||
|
|
||||||
assert "id" in payload
|
|
||||||
assert "name" in payload
|
|
||||||
assert "created" in payload
|
|
||||||
assert "description" in payload
|
|
||||||
assert "context_length" in payload
|
|
||||||
assert "architecture" in payload
|
|
||||||
assert "pricing" in payload
|
|
||||||
assert "sats_pricing" in payload
|
|
||||||
assert "per_request_limits" in payload
|
|
||||||
assert "top_provider" in payload
|
|
||||||
assert "enabled" in payload
|
|
||||||
assert "upstream_provider_id" in payload
|
|
||||||
|
|
||||||
assert isinstance(payload["architecture"], str)
|
|
||||||
assert isinstance(payload["pricing"], str)
|
|
||||||
|
|
||||||
|
|
||||||
def test_original_model_not_mutated(standard_model: Model) -> None:
|
|
||||||
"""Verify that the original model object is not mutated."""
|
|
||||||
original_prompt = standard_model.pricing.prompt
|
|
||||||
original_completion = standard_model.pricing.completion
|
|
||||||
|
|
||||||
_model_to_row_payload(standard_model)
|
|
||||||
|
|
||||||
assert standard_model.pricing.prompt == original_prompt
|
|
||||||
assert standard_model.pricing.completion == original_completion
|
|
||||||
+155
-151
@@ -21,6 +21,7 @@ import {
|
|||||||
AdminModel,
|
AdminModel,
|
||||||
} from '@/lib/api/services/admin';
|
} from '@/lib/api/services/admin';
|
||||||
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
|
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
|
||||||
|
import { BatchOverrideDialog } from '@/components/BatchOverrideDialog';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -286,7 +287,9 @@ function ProviderBalance({
|
|||||||
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
|
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(
|
||||||
|
invoiceData.payment_request
|
||||||
|
)}`}
|
||||||
alt='Lightning Invoice QR Code'
|
alt='Lightning Invoice QR Code'
|
||||||
className='h-64 w-64'
|
className='h-64 w-64'
|
||||||
/>
|
/>
|
||||||
@@ -403,6 +406,9 @@ export default function ProvidersPage() {
|
|||||||
mode: 'create',
|
mode: 'create',
|
||||||
initialData: null,
|
initialData: null,
|
||||||
});
|
});
|
||||||
|
const [batchOverrideProviderId, setBatchOverrideProviderId] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<CreateUpstreamProvider>({
|
const [formData, setFormData] = useState<CreateUpstreamProvider>({
|
||||||
provider_type: 'openrouter',
|
provider_type: 'openrouter',
|
||||||
@@ -482,6 +488,25 @@ export default function ProvidersPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteModelMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
providerId,
|
||||||
|
modelId,
|
||||||
|
}: {
|
||||||
|
providerId: number;
|
||||||
|
modelId: string;
|
||||||
|
}) => AdminService.deleteProviderModel(providerId, modelId),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['provider-models', variables.providerId],
|
||||||
|
});
|
||||||
|
toast.success('Model deleted successfully');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(`Failed to delete model: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleCreateAccount = async () => {
|
const handleCreateAccount = async () => {
|
||||||
setIsCreatingAccount(true);
|
setIsCreatingAccount(true);
|
||||||
try {
|
try {
|
||||||
@@ -560,6 +585,12 @@ export default function ProvidersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteModel = (providerId: number, modelId: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this model?')) {
|
||||||
|
deleteModelMutation.mutate({ providerId, modelId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getDefaultBaseUrl = (type: string) => {
|
const getDefaultBaseUrl = (type: string) => {
|
||||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||||
return providerType?.default_base_url || '';
|
return providerType?.default_base_url || '';
|
||||||
@@ -627,6 +658,10 @@ export default function ProvidersPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBatchOverride = (providerId: number) => {
|
||||||
|
setBatchOverrideProviderId(providerId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar variant='inset' />
|
<AppSidebar variant='inset' />
|
||||||
@@ -933,15 +968,68 @@ export default function ProvidersPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : providerModels &&
|
) : providerModels &&
|
||||||
viewingModels === provider.id ? (
|
viewingModels === provider.id ? (
|
||||||
providerModels.remote_models.length === 0 ? (
|
<Tabs
|
||||||
// No provided models - show custom models directly without tabs
|
defaultValue={
|
||||||
<div className='space-y-2'>
|
providerModels.remote_models.length > 0
|
||||||
{providerModels.db_models.length === 0 ? (
|
? 'provided'
|
||||||
<div className='flex flex-col items-center justify-center gap-2 py-4'>
|
: 'custom'
|
||||||
|
}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
<TabsList className='grid w-full grid-cols-2'>
|
||||||
|
<TabsTrigger
|
||||||
|
value='provided'
|
||||||
|
className='text-xs sm:text-sm'
|
||||||
|
>
|
||||||
|
<span className='hidden sm:inline'>
|
||||||
|
Provided Models
|
||||||
|
</span>
|
||||||
|
<span className='sm:hidden'>Provided</span>
|
||||||
|
<Badge
|
||||||
|
variant='secondary'
|
||||||
|
className='ml-1 text-xs sm:ml-2'
|
||||||
|
>
|
||||||
|
{providerModels.remote_models.length}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='custom'
|
||||||
|
className='text-xs sm:text-sm'
|
||||||
|
>
|
||||||
|
<span className='hidden sm:inline'>
|
||||||
|
Custom Models
|
||||||
|
</span>
|
||||||
|
<span className='sm:hidden'>Custom</span>
|
||||||
|
<Badge
|
||||||
|
variant='secondary'
|
||||||
|
className='ml-1 text-xs sm:ml-2'
|
||||||
|
>
|
||||||
|
{providerModels.db_models.length}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent
|
||||||
|
value='custom'
|
||||||
|
className='mt-4 space-y-2'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
{providerModels.db_models.length > 0 && (
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
No models configured. Add custom models
|
Custom models override or extend the
|
||||||
to use this provider.
|
provider's catalog.
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() =>
|
||||||
|
handleBatchOverride(provider.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Database className='mr-2 h-4 w-4' />
|
||||||
|
Batch Override
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -953,6 +1041,11 @@ export default function ProvidersPage() {
|
|||||||
Add Custom Model
|
Add Custom Model
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{providerModels.db_models.length === 0 ? (
|
||||||
|
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||||
|
No custom models configured
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
{providerModels.db_models.map((model) => (
|
{providerModels.db_models.map((model) => (
|
||||||
@@ -1000,103 +1093,48 @@ export default function ProvidersPage() {
|
|||||||
>
|
>
|
||||||
<Pencil className='h-4 w-4' />
|
<Pencil className='h-4 w-4' />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='text-destructive hover:text-destructive h-8 w-8'
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteModel(
|
||||||
|
provider.id,
|
||||||
|
model.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
deleteModelMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TabsContent>
|
||||||
) : (
|
<TabsContent
|
||||||
// Has provided models - show tabs
|
value='provided'
|
||||||
<Tabs
|
className='mt-4 space-y-2'
|
||||||
defaultValue='provided'
|
|
||||||
className='w-full'
|
|
||||||
>
|
>
|
||||||
<TabsList className='grid w-full grid-cols-2'>
|
{providerModels.remote_models.length > 0 ? (
|
||||||
<TabsTrigger
|
<>
|
||||||
value='provided'
|
<div className='text-muted-foreground mb-3 text-sm'>
|
||||||
className='text-xs sm:text-sm'
|
Models automatically discovered from the
|
||||||
>
|
provider's catalog.
|
||||||
<span className='hidden sm:inline'>
|
|
||||||
Provided Models
|
|
||||||
</span>
|
|
||||||
<span className='sm:hidden'>
|
|
||||||
Provided
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant='secondary'
|
|
||||||
className='ml-1 text-xs sm:ml-2'
|
|
||||||
>
|
|
||||||
{providerModels.remote_models.length}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value='custom'
|
|
||||||
className='text-xs sm:text-sm'
|
|
||||||
>
|
|
||||||
<span className='hidden sm:inline'>
|
|
||||||
Custom Models
|
|
||||||
</span>
|
|
||||||
<span className='sm:hidden'>Custom</span>
|
|
||||||
<Badge
|
|
||||||
variant='secondary'
|
|
||||||
className='ml-1 text-xs sm:ml-2'
|
|
||||||
>
|
|
||||||
{providerModels.db_models.length}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent
|
|
||||||
value='custom'
|
|
||||||
className='mt-4 space-y-2'
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
{providerModels.db_models.length > 0 && (
|
|
||||||
<div className='text-muted-foreground text-sm'>
|
|
||||||
Custom models override or extend the
|
|
||||||
provider's catalog.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
onClick={() =>
|
|
||||||
handleAddModel(provider.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className='mr-2 h-4 w-4' />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{providerModels.db_models.length === 0 ? (
|
|
||||||
<div className='text-muted-foreground py-4 text-center text-sm'>
|
|
||||||
No custom models configured
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
{providerModels.db_models.map(
|
{providerModels.remote_models.map(
|
||||||
(model) => (
|
(model) => (
|
||||||
<div
|
<div
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
|
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
|
||||||
>
|
>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
|
<div className='truncate font-mono text-sm font-medium'>
|
||||||
<span className='truncate font-mono text-sm font-medium'>
|
{model.id}
|
||||||
{model.id}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
model.enabled
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
className='w-fit text-xs'
|
|
||||||
>
|
|
||||||
{model.enabled
|
|
||||||
? 'Enabled'
|
|
||||||
: 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground mt-1 text-xs break-words'>
|
<div className='text-muted-foreground mt-1 text-xs break-words'>
|
||||||
{model.description ||
|
{model.description ||
|
||||||
@@ -1109,79 +1147,32 @@ export default function ProvidersPage() {
|
|||||||
tokens
|
tokens
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='outline'
|
||||||
size='icon'
|
size='sm'
|
||||||
className='h-8 w-8'
|
className='h-7 text-xs'
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleEditModel(
|
handleOverrideModel(
|
||||||
provider.id,
|
provider.id,
|
||||||
model
|
model
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Pencil className='h-4 w-4' />
|
<Plus className='mr-1 h-3 w-3' />
|
||||||
|
Override
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</TabsContent>
|
) : (
|
||||||
<TabsContent
|
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||||
value='provided'
|
No provided models available
|
||||||
className='mt-4 space-y-2'
|
|
||||||
>
|
|
||||||
{providerModels.remote_models.length >
|
|
||||||
0 && (
|
|
||||||
<div className='text-muted-foreground mb-3 text-sm'>
|
|
||||||
Models automatically discovered from the
|
|
||||||
provider's catalog.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{providerModels.remote_models.map(
|
|
||||||
(model) => (
|
|
||||||
<div
|
|
||||||
key={model.id}
|
|
||||||
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
|
|
||||||
>
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<div className='truncate font-mono text-sm font-medium'>
|
|
||||||
{model.id}
|
|
||||||
</div>
|
|
||||||
<div className='text-muted-foreground mt-1 text-xs break-words'>
|
|
||||||
{model.description ||
|
|
||||||
model.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
|
||||||
{model.context_length?.toLocaleString()}{' '}
|
|
||||||
tokens
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
className='h-7 text-xs'
|
|
||||||
onClick={() =>
|
|
||||||
handleOverrideModel(
|
|
||||||
provider.id,
|
|
||||||
model
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className='mr-1 h-3 w-3' />
|
|
||||||
Override
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
)}
|
||||||
</Tabs>
|
</TabsContent>
|
||||||
)
|
</Tabs>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1357,6 +1348,19 @@ export default function ProvidersPage() {
|
|||||||
mode={modelDialogState.mode}
|
mode={modelDialogState.mode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{batchOverrideProviderId && (
|
||||||
|
<BatchOverrideDialog
|
||||||
|
providerId={batchOverrideProviderId}
|
||||||
|
isOpen={!!batchOverrideProviderId}
|
||||||
|
onClose={() => setBatchOverrideProviderId(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['provider-models', batchOverrideProviderId],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -248,7 +248,9 @@ export function AddProviderModelDialog({
|
|||||||
const pricing = model.pricing as Record<string, number>;
|
const pricing = model.pricing as Record<string, number>;
|
||||||
const topProvider = model.top_provider as Record<string, unknown> | null;
|
const topProvider = model.top_provider as Record<string, unknown> | null;
|
||||||
|
|
||||||
form.setValue('id', model.id);
|
if (!isOverride) {
|
||||||
|
form.setValue('id', model.id);
|
||||||
|
}
|
||||||
form.setValue('name', model.name);
|
form.setValue('name', model.name);
|
||||||
form.setValue('description', model.description || '');
|
form.setValue('description', model.description || '');
|
||||||
form.setValue('context_length', model.context_length);
|
form.setValue('context_length', model.context_length);
|
||||||
@@ -425,7 +427,7 @@ export function AddProviderModelDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!isEdit && !isOverride && (
|
{!isEdit && (
|
||||||
<div className='bg-muted/30 rounded-md border p-3'>
|
<div className='bg-muted/30 rounded-md border p-3'>
|
||||||
<div className='mb-2 text-sm font-medium'>Presets</div>
|
<div className='mb-2 text-sm font-medium'>Presets</div>
|
||||||
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
|
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
|
||||||
@@ -488,8 +490,9 @@ export function AddProviderModelDialog({
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground mt-1 text-xs'>
|
<div className='text-muted-foreground mt-1 text-xs'>
|
||||||
Prefill fields from a preset model definition, then adjust as
|
{isOverride
|
||||||
needed.
|
? 'Apply pricing and settings from a preset model (keeping the model ID unchanged).'
|
||||||
|
: 'Prefill fields from a preset model definition, then adjust as needed.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -805,45 +808,6 @@ export function AddProviderModelDialog({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='max_prompt_cost'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Max Prompt Cost</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type='number' step='0.0001' {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='max_completion_cost'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Max Completion Cost</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type='number' step='0.0001' {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='max_cost'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Max Total Cost</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type='number' step='0.0001' {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { AdminService } from '@/lib/api/services/admin';
|
||||||
|
import { Loader2, Database } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface BatchOverrideDialogProps {
|
||||||
|
providerId: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchOverrideDialog({
|
||||||
|
providerId,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: BatchOverrideDialogProps) {
|
||||||
|
const [jsonInput, setJsonInput] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const sampleJson = {
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'model-id-1',
|
||||||
|
name: 'Model Name 1',
|
||||||
|
description: 'Description...',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
context_length: 8192,
|
||||||
|
architecture: {
|
||||||
|
modality: 'text',
|
||||||
|
input_modalities: ['text'],
|
||||||
|
output_modalities: ['text'],
|
||||||
|
tokenizer: '',
|
||||||
|
instruct_type: null,
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
prompt: 0.0,
|
||||||
|
completion: 0.0,
|
||||||
|
request: 0.0,
|
||||||
|
image: 0.0,
|
||||||
|
web_search: 0.0,
|
||||||
|
internal_reasoning: 0.0,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchOverride = async () => {
|
||||||
|
if (!jsonInput.trim()) {
|
||||||
|
toast.error('Please enter JSON content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(jsonInput);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.models || !Array.isArray(data.models)) {
|
||||||
|
throw new Error('JSON match follow structure: { "models": [...] }');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await AdminService.batchOverrideProviderModels(
|
||||||
|
providerId,
|
||||||
|
data.models
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(result.message || 'Batch override successful');
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
setJsonInput('');
|
||||||
|
} else {
|
||||||
|
throw new Error('Batch override failed');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Batch override failed';
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className='sm:max-w-[800px]'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className='flex items-center gap-2'>
|
||||||
|
<Database className='h-4 w-4' />
|
||||||
|
Batch Override Models
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste a JSON object with a "models" array containing model
|
||||||
|
definitions. Existing models with the same ID will be updated.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className='grid gap-4 py-4'>
|
||||||
|
<Textarea
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={(e) => setJsonInput(e.target.value)}
|
||||||
|
placeholder={JSON.stringify(sampleJson, null, 2)}
|
||||||
|
className='min-h-[400px] font-mono text-xs'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant='outline' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleBatchOverride} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Batch Override'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { WalletService } from '@/lib/api/services/wallet';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
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;
|
||||||
|
apiKey?: string;
|
||||||
|
onApiKeyChange?: (apiKey: string) => void;
|
||||||
|
costPerKeyMsats?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChildKeyCreator({
|
||||||
|
baseUrl,
|
||||||
|
apiKey: propApiKey,
|
||||||
|
onApiKeyChange,
|
||||||
|
costPerKeyMsats,
|
||||||
|
}: ChildKeyCreatorProps) {
|
||||||
|
const [internalApiKey, setInternalApiKey] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
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;
|
||||||
|
parent_balance: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const activeApiKey = propApiKey ?? internalApiKey;
|
||||||
|
|
||||||
|
const handleApiKeyChange = (val: string) => {
|
||||||
|
setInternalApiKey(val);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let allNewKeys: string[] = [];
|
||||||
|
let totalCost = 0;
|
||||||
|
let lastParentBalance = 0;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
allNewKeys = [...allNewKeys, ...result.api_keys];
|
||||||
|
}
|
||||||
|
totalCost += result.cost_msats;
|
||||||
|
lastParentBalance = result.parent_balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewKeys(allNewKeys);
|
||||||
|
setResultInfo({
|
||||||
|
cost_msats: totalCost,
|
||||||
|
parent_balance: lastParentBalance,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`${allNewKeys.length} child API key${
|
||||||
|
allNewKeys.length > 1 ? 's' : ''
|
||||||
|
} created successfully`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create child key:', error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to create child key'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
toast.success('API key copied to clipboard');
|
||||||
|
setTimeout(() => setCopiedKey(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAllToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(newKeys.join('\n'));
|
||||||
|
toast.success('All API keys copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<CardTitle>Create Child API Key</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate secondary API keys that share your account balance.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{costPerKeyMsats !== undefined && (
|
||||||
|
<div className='text-right'>
|
||||||
|
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||||
|
Unit Cost
|
||||||
|
</p>
|
||||||
|
<p className='text-primary text-sm font-bold'>
|
||||||
|
{costPerKeyMsats / 1000} sats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{baseUrl && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
Parent API Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={activeApiKey}
|
||||||
|
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||||
|
placeholder='sk-...'
|
||||||
|
className='font-mono text-sm'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-6'>
|
||||||
|
{configs.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className='bg-muted/30 relative space-y-4 rounded-lg border p-4 pt-6'
|
||||||
|
>
|
||||||
|
{configs.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='text-destructive hover:bg-destructive/10 hover:text-destructive absolute top-2 right-2 h-7 w-7'
|
||||||
|
onClick={() => removeConfig(config.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className='flex flex-col gap-4 sm:flex-row sm:items-end'>
|
||||||
|
<div className='w-full space-y-2 sm:w-32'>
|
||||||
|
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
Number of keys
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={config.count}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value);
|
||||||
|
updateConfig(config.id, {
|
||||||
|
count: isNaN(val)
|
||||||
|
? 1
|
||||||
|
: Math.max(1, Math.min(50, val)),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className='h-9'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex-1'>
|
||||||
|
<KeyOptions
|
||||||
|
balanceLimit={config.balanceLimit}
|
||||||
|
setBalanceLimit={(val) =>
|
||||||
|
updateConfig(config.id, { balanceLimit: val })
|
||||||
|
}
|
||||||
|
validityDate={config.validityDate}
|
||||||
|
setValidityDate={(val) =>
|
||||||
|
updateConfig(config.id, { validityDate: val })
|
||||||
|
}
|
||||||
|
balanceLimitReset={config.balanceLimitReset}
|
||||||
|
setBalanceLimitReset={(val) =>
|
||||||
|
updateConfig(config.id, { balanceLimitReset: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={addConfig}
|
||||||
|
className='gap-2 border-dashed'
|
||||||
|
>
|
||||||
|
<Plus className='h-4 w-4' />
|
||||||
|
Add Another Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center justify-between gap-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{costPerKeyMsats && (
|
||||||
|
<p>
|
||||||
|
Total Cost:{' '}
|
||||||
|
<span className='text-foreground font-medium'>
|
||||||
|
{costPerKeyMsats *
|
||||||
|
configs.reduce(
|
||||||
|
(acc, c) => acc + Number(c.count),
|
||||||
|
0
|
||||||
|
)}{' '}
|
||||||
|
mSats
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Each key creation has a small one-time fee.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{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'>
|
||||||
|
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
|
||||||
|
Generated
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className='text-green-700 dark:text-green-500'>
|
||||||
|
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
|
||||||
|
You won't be able to see them again.
|
||||||
|
{resultInfo && (
|
||||||
|
<div className='mt-2 font-medium opacity-80'>
|
||||||
|
Total Cost: {resultInfo.cost_msats / 1000} sats | New
|
||||||
|
Balance: {resultInfo.parent_balance / 1000} sats
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-xs font-medium uppercase'>
|
||||||
|
Generated Keys ({newKeys.length})
|
||||||
|
</span>
|
||||||
|
{newKeys.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='h-7 text-[10px] uppercase'
|
||||||
|
onClick={copyAllToClipboard}
|
||||||
|
>
|
||||||
|
<Copy className='mr-1 h-3 w-3' />
|
||||||
|
Copy All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
{newKeys.map((key, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='group relative flex items-center gap-2'
|
||||||
|
>
|
||||||
|
<code className='bg-muted/50 flex-1 rounded border p-2.5 font-mono text-[10px] break-all sm:text-xs'>
|
||||||
|
{key}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
size='icon'
|
||||||
|
variant='ghost'
|
||||||
|
className='h-8 w-8 shrink-0'
|
||||||
|
onClick={() => copyToClipboard(key)}
|
||||||
|
>
|
||||||
|
{copiedKey === key ? (
|
||||||
|
<Check className='h-3.5 w-3.5 text-green-500' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newKeys.length > 3 && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
Bulk Export (All Keys)
|
||||||
|
</label>
|
||||||
|
<div className='relative'>
|
||||||
|
<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'
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='secondary'
|
||||||
|
className='absolute right-2 bottom-2 h-7 text-[10px]'
|
||||||
|
onClick={copyAllToClipboard}
|
||||||
|
>
|
||||||
|
Copy Bulk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-lg'>Check Child Key Status</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View the current spending, limit, and expiration status of any child
|
||||||
|
key.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
Child API Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={childKeyToCheck}
|
||||||
|
onChange={(e) => setChildKeyToCheck(e.target.value)}
|
||||||
|
placeholder='sk-...'
|
||||||
|
className='font-mono text-sm'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleCheckKey}
|
||||||
|
disabled={checking || !childKeyToCheck}
|
||||||
|
variant='outline'
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
{checking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
|
Checking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCcw className='mr-2 h-4 w-4' />
|
||||||
|
Check Status
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{keyStatus && (
|
||||||
|
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<span className='text-muted-foreground'>Total Spent:</span>
|
||||||
|
<span className='font-mono font-medium'>
|
||||||
|
{keyStatus.total_spent} mSats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{keyStatus.balance_limit !== null && (
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<span className='text-muted-foreground'>Limit:</span>
|
||||||
|
<span className='font-mono font-medium'>
|
||||||
|
{keyStatus.balance_limit} mSats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{keyStatus.validity_date !== null && (
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<span className='text-muted-foreground'>Expires:</span>
|
||||||
|
<span className='font-mono font-medium'>
|
||||||
|
{new Date(
|
||||||
|
keyStatus.validity_date * 1000
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='flex gap-2 pt-2'>
|
||||||
|
{keyStatus.is_drained && (
|
||||||
|
<Badge variant='destructive'>Drained</Badge>
|
||||||
|
)}
|
||||||
|
{keyStatus.is_expired && (
|
||||||
|
<Badge variant='destructive'>Expired</Badge>
|
||||||
|
)}
|
||||||
|
{!keyStatus.is_drained && !keyStatus.is_expired && (
|
||||||
|
<Badge className='bg-green-600 hover:bg-green-700'>
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Zap, Calendar, Shield } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface KeyOptionsProps {
|
||||||
|
balanceLimit: string;
|
||||||
|
setBalanceLimit: (val: string) => void;
|
||||||
|
validityDate: string;
|
||||||
|
setValidityDate: (val: string) => void;
|
||||||
|
balanceLimitReset: string;
|
||||||
|
setBalanceLimitReset: (val: string) => void;
|
||||||
|
showBalanceLimit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyOptions({
|
||||||
|
balanceLimit,
|
||||||
|
setBalanceLimit,
|
||||||
|
validityDate,
|
||||||
|
setValidityDate,
|
||||||
|
balanceLimitReset,
|
||||||
|
setBalanceLimitReset,
|
||||||
|
showBalanceLimit = true,
|
||||||
|
}: KeyOptionsProps) {
|
||||||
|
return (
|
||||||
|
<div className='grid gap-4 sm:grid-cols-3'>
|
||||||
|
{showBalanceLimit && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
<Zap className='h-3 w-3' />
|
||||||
|
Balance Limit (mSats)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
placeholder='No limit'
|
||||||
|
value={balanceLimit}
|
||||||
|
onChange={(e) => setBalanceLimit(e.target.value)}
|
||||||
|
className='h-9 text-xs'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
<Calendar className='h-3 w-3' />
|
||||||
|
Validity Date
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type='date'
|
||||||
|
value={validityDate}
|
||||||
|
onChange={(e) => setValidityDate(e.target.value)}
|
||||||
|
className='h-9 text-xs'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
<Shield className='h-3 w-3' />
|
||||||
|
Reset Policy
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={balanceLimitReset}
|
||||||
|
onChange={(e) => setBalanceLimitReset(e.target.value)}
|
||||||
|
className='bg-background border-input flex h-9 w-full rounded-md border px-3 py-1 text-xs shadow-sm transition-colors'
|
||||||
|
>
|
||||||
|
<option value=''>None</option>
|
||||||
|
<option value='daily'>Daily</option>
|
||||||
|
<option value='weekly'>Weekly</option>
|
||||||
|
<option value='monthly'>Monthly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,19 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
|
||||||
type WalletSnapshot = {
|
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||||
apiKey: string;
|
|
||||||
balanceMsats: number;
|
|
||||||
reservedMsats: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RefundReceipt = {
|
|
||||||
token?: string;
|
|
||||||
recipient?: string;
|
|
||||||
sats?: string;
|
|
||||||
msats?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ApiKeyManagerProps {
|
interface ApiKeyManagerProps {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -51,12 +40,28 @@ async function fetchWalletInfo(
|
|||||||
api_key: string;
|
api_key: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
reserved?: 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 {
|
return {
|
||||||
apiKey: payload.api_key || apiKey,
|
apiKey: payload.api_key || apiKey,
|
||||||
balanceMsats: payload.balance ?? 0,
|
balanceMsats: payload.balance ?? 0,
|
||||||
reservedMsats: payload.reserved ?? 0,
|
reservedMsats: payload.reserved ?? 0,
|
||||||
|
isChild: payload.is_child,
|
||||||
|
parentKey: payload.parent_key,
|
||||||
|
totalRequests: payload.total_requests,
|
||||||
|
totalSpent: payload.total_spent,
|
||||||
|
balanceLimit: payload.balance_limit,
|
||||||
|
balanceLimitReset: payload.balance_limit_reset,
|
||||||
|
validityDate: payload.validity_date,
|
||||||
|
childKeys: payload.child_keys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { KeyOptions } from '@/components/key-options';
|
||||||
|
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
|
||||||
|
|
||||||
type WalletSnapshot = {
|
export type RefundReceipt = {
|
||||||
apiKey: string;
|
|
||||||
balanceMsats: number;
|
|
||||||
reservedMsats: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RefundReceipt = {
|
|
||||||
token?: string;
|
token?: string;
|
||||||
recipient?: string;
|
recipient?: string;
|
||||||
sats?: string;
|
sats?: string;
|
||||||
@@ -53,12 +49,28 @@ async function fetchWalletInfo(
|
|||||||
api_key: string;
|
api_key: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
reserved?: 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 {
|
return {
|
||||||
apiKey: payload.api_key || apiKey,
|
apiKey: payload.api_key || apiKey,
|
||||||
balanceMsats: payload.balance ?? 0,
|
balanceMsats: payload.balance ?? 0,
|
||||||
reservedMsats: payload.reserved ?? 0,
|
reservedMsats: payload.reserved ?? 0,
|
||||||
|
isChild: payload.is_child,
|
||||||
|
parentKey: payload.parent_key,
|
||||||
|
totalRequests: payload.total_requests,
|
||||||
|
totalSpent: payload.total_spent,
|
||||||
|
balanceLimit: payload.balance_limit,
|
||||||
|
balanceLimitReset: payload.balance_limit_reset,
|
||||||
|
validityDate: payload.validity_date,
|
||||||
|
childKeys: payload.child_keys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +98,11 @@ export function CashuPaymentWorkflow({
|
|||||||
const [isTopupLoading, setIsTopupLoading] = useState(false);
|
const [isTopupLoading, setIsTopupLoading] = useState(false);
|
||||||
const [isRefunding, setIsRefunding] = useState(false);
|
const [isRefunding, setIsRefunding] = useState(false);
|
||||||
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
|
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
|
||||||
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
|
|
||||||
const [hasInteractedManage, setHasInteractedManage] = useState(false);
|
const [hasInteractedManage, setHasInteractedManage] = useState(false);
|
||||||
const [hasInteractedTopup, setHasInteractedTopup] = 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();
|
const activeApiKey = apiKeyInput.trim();
|
||||||
|
|
||||||
@@ -121,6 +135,15 @@ export function CashuPaymentWorkflow({
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
initial_balance_token: initialToken.trim(),
|
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(
|
const response = await fetch(
|
||||||
`${baseUrl}/v1/balance/create?${params.toString()}`,
|
`${baseUrl}/v1/balance/create?${params.toString()}`,
|
||||||
{
|
{
|
||||||
@@ -135,11 +158,25 @@ export function CashuPaymentWorkflow({
|
|||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
api_key: string;
|
api_key: string;
|
||||||
balance: number;
|
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 = {
|
const snapshot: WalletSnapshot = {
|
||||||
apiKey: payload.api_key,
|
apiKey: payload.api_key,
|
||||||
balanceMsats: payload.balance ?? 0,
|
balanceMsats: payload.balance ?? 0,
|
||||||
reservedMsats: 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);
|
setApiKeyInput(snapshot.apiKey);
|
||||||
@@ -154,7 +191,14 @@ export function CashuPaymentWorkflow({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsCreatingKey(false);
|
setIsCreatingKey(false);
|
||||||
}
|
}
|
||||||
}, [initialToken, baseUrl, onApiKeyCreated]);
|
}, [
|
||||||
|
initialToken,
|
||||||
|
baseUrl,
|
||||||
|
onApiKeyCreated,
|
||||||
|
balanceLimit,
|
||||||
|
balanceLimitReset,
|
||||||
|
validityDate,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSyncBalance = useCallback(async (): Promise<void> => {
|
const handleSyncBalance = useCallback(async (): Promise<void> => {
|
||||||
if (!activeApiKey) {
|
if (!activeApiKey) {
|
||||||
@@ -256,11 +300,10 @@ export function CashuPaymentWorkflow({
|
|||||||
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
|
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showCreateDetails =
|
|
||||||
hasInteractedCreate || initialToken.trim().length > 0;
|
|
||||||
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
|
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
|
||||||
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
|
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
|
||||||
const canTopup = Boolean(activeApiKey);
|
const canTopup = Boolean(activeApiKey);
|
||||||
|
const showCreateDetails = initialToken.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -285,12 +328,21 @@ export function CashuPaymentWorkflow({
|
|||||||
value={initialToken}
|
value={initialToken}
|
||||||
onChange={(event) => setInitialToken(event.target.value)}
|
onChange={(event) => setInitialToken(event.target.value)}
|
||||||
placeholder='cashuA1...'
|
placeholder='cashuA1...'
|
||||||
rows={showCreateDetails ? 4 : 2}
|
rows={4}
|
||||||
className='font-mono text-sm transition-all duration-200'
|
className='font-mono text-sm transition-all duration-200'
|
||||||
onFocus={() => setHasInteractedCreate(true)}
|
|
||||||
/>
|
/>
|
||||||
{showCreateDetails && (
|
<div className='space-y-4'>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<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
|
<Button
|
||||||
onClick={handleCreateKey}
|
onClick={handleCreateKey}
|
||||||
disabled={isCreatingKey}
|
disabled={isCreatingKey}
|
||||||
@@ -298,11 +350,13 @@ export function CashuPaymentWorkflow({
|
|||||||
>
|
>
|
||||||
{isCreatingKey ? 'Creating…' : 'Create API key'}
|
{isCreatingKey ? 'Creating…' : 'Create API key'}
|
||||||
</Button>
|
</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.
|
Redeems instantly and returns <code>sk-</code> key.
|
||||||
|
<br />
|
||||||
|
Optional limits can be set above for enhanced security.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -11,27 +11,24 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
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 { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
||||||
import { ApiKeyManager } from './api-key-manager';
|
import { ApiKeyManager } from './api-key-manager';
|
||||||
|
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
|
||||||
|
import { ChildKeyCreator } from '@/components/child-key-creator';
|
||||||
|
|
||||||
type NodeInfo = {
|
type NodeInfo = {
|
||||||
name: string;
|
name?: string;
|
||||||
description: string;
|
description?: string;
|
||||||
version: string;
|
version?: string;
|
||||||
npub?: string | null;
|
http_url?: string;
|
||||||
mints: string[];
|
onion_url?: string;
|
||||||
http_url?: string | null;
|
npub?: string;
|
||||||
onion_url?: string | null;
|
mints?: string[];
|
||||||
};
|
child_key_cost_msats?: number;
|
||||||
|
|
||||||
type WalletSnapshot = {
|
|
||||||
apiKey: string;
|
|
||||||
balanceMsats: number;
|
|
||||||
reservedMsats: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RefundReceipt = {
|
|
||||||
token?: string;
|
token?: string;
|
||||||
recipient?: string;
|
recipient?: string;
|
||||||
sats?: string;
|
sats?: string;
|
||||||
@@ -283,7 +280,7 @@ export function CheatSheet(): JSX.Element {
|
|||||||
Cashu mints
|
Cashu mints
|
||||||
</p>
|
</p>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{nodeInfo.mints.length ? (
|
{nodeInfo.mints?.length ? (
|
||||||
nodeInfo.mints.map((mint) => (
|
nodeInfo.mints.map((mint) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={mint}
|
key={mint}
|
||||||
@@ -362,10 +359,12 @@ export function CheatSheet(): JSX.Element {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Tabs defaultValue='cashu' className='w-full'>
|
<Tabs defaultValue='cashu' className='w-full'>
|
||||||
<TabsList className='grid w-full grid-cols-3'>
|
<TabsList className='grid w-full grid-cols-5'>
|
||||||
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
|
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
|
||||||
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
|
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
|
||||||
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
||||||
|
<TabsTrigger value='details'>Key Details</TabsTrigger>
|
||||||
|
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='cashu' className='space-y-4'>
|
<TabsContent value='cashu' className='space-y-4'>
|
||||||
@@ -422,6 +421,25 @@ export function CheatSheet(): JSX.Element {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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}
|
||||||
|
apiKey={apiKeyInput}
|
||||||
|
onApiKeyChange={handleApiKeyChanged}
|
||||||
|
costPerKeyMsats={nodeInfo?.child_key_cost_msats}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { type JSX, useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
RefreshCcw,
|
||||||
|
ShieldCheck,
|
||||||
|
History,
|
||||||
|
Users,
|
||||||
|
RotateCcw,
|
||||||
|
KeyRound,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { WalletService } from '@/lib/api/services/wallet';
|
||||||
|
|
||||||
|
export type ChildKeyInfo = {
|
||||||
|
api_key: string;
|
||||||
|
total_requests: number;
|
||||||
|
total_spent: number;
|
||||||
|
balance_limit: number | null;
|
||||||
|
balance_limit_reset: string | null;
|
||||||
|
validity_date: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WalletSnapshot = {
|
||||||
|
apiKey: string;
|
||||||
|
balanceMsats: number;
|
||||||
|
reservedMsats: number;
|
||||||
|
isChild: boolean;
|
||||||
|
parentKey: string | null;
|
||||||
|
totalRequests: number;
|
||||||
|
totalSpent: number;
|
||||||
|
balanceLimit: number | null;
|
||||||
|
balanceLimitReset: string | null;
|
||||||
|
validityDate: number | null;
|
||||||
|
childKeys?: ChildKeyInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface KeyInfoDetailsProps {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
walletInfo?: WalletSnapshot | null;
|
||||||
|
onApiKeyChanged?: (apiKey: string) => void;
|
||||||
|
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyInfoDetails({
|
||||||
|
baseUrl,
|
||||||
|
apiKey = '',
|
||||||
|
walletInfo = null,
|
||||||
|
onApiKeyChanged,
|
||||||
|
onWalletInfoUpdated,
|
||||||
|
}: KeyInfoDetailsProps): JSX.Element {
|
||||||
|
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [isResetting, setIsResetting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sync internal state with props if they change
|
||||||
|
useEffect(() => {
|
||||||
|
setApiKeyInput(apiKey);
|
||||||
|
}, [apiKey]);
|
||||||
|
|
||||||
|
const fetchDetails = useCallback(
|
||||||
|
async (keyToFetch: string) => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||||
|
headers: { Authorization: `Bearer ${keyToFetch}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch key info');
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
const snapshot: WalletSnapshot = {
|
||||||
|
apiKey: payload.api_key || keyToFetch,
|
||||||
|
balanceMsats: payload.balance ?? 0,
|
||||||
|
reservedMsats: payload.reserved ?? 0,
|
||||||
|
isChild: payload.is_child,
|
||||||
|
parentKey: payload.parent_key,
|
||||||
|
totalRequests: payload.total_requests,
|
||||||
|
totalSpent: payload.total_spent,
|
||||||
|
balanceLimit: payload.balance_limit,
|
||||||
|
balanceLimitReset: payload.balance_limit_reset,
|
||||||
|
validityDate: payload.validity_date,
|
||||||
|
childKeys: payload.child_keys,
|
||||||
|
};
|
||||||
|
onWalletInfoUpdated?.(snapshot);
|
||||||
|
toast.success('Key details synced');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to fetch details'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[baseUrl, onWalletInfoUpdated]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (!apiKeyInput) return;
|
||||||
|
await fetchDetails(apiKeyInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyChange = (newKey: string) => {
|
||||||
|
setApiKeyInput(newKey);
|
||||||
|
onApiKeyChanged?.(newKey);
|
||||||
|
// Optionally clear info when key changes
|
||||||
|
if (newKey !== apiKey) {
|
||||||
|
onWalletInfoUpdated?.(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (value: string) => {
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSpent = async (childKey: string) => {
|
||||||
|
if (!walletInfo || walletInfo.isChild) return;
|
||||||
|
|
||||||
|
setIsResetting(childKey);
|
||||||
|
try {
|
||||||
|
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
|
||||||
|
toast.success('Child key spent reset');
|
||||||
|
await fetchDetails(apiKeyInput);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to reset child key'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsResetting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSats = (msats: number) =>
|
||||||
|
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||||
|
const formatMsats = (msats: number) =>
|
||||||
|
new Intl.NumberFormat('en-US').format(msats);
|
||||||
|
const formatDate = (timestamp: number | null) =>
|
||||||
|
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='space-y-1'>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||||
|
<KeyRound className='text-primary h-5 w-5' />
|
||||||
|
Key Information
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter an API key to view its balance, consumption, and child keys.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||||
|
<Input
|
||||||
|
value={apiKeyInput}
|
||||||
|
onChange={(e) => handleKeyChange(e.target.value)}
|
||||||
|
placeholder='sk-...'
|
||||||
|
className='font-mono text-sm'
|
||||||
|
/>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='icon'
|
||||||
|
className='h-10 w-10 shrink-0'
|
||||||
|
onClick={() => handleCopy(apiKeyInput)}
|
||||||
|
disabled={!apiKeyInput}
|
||||||
|
>
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='secondary'
|
||||||
|
size='sm'
|
||||||
|
className='min-w-[80px] gap-1'
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing || !apiKeyInput}
|
||||||
|
>
|
||||||
|
<RefreshCcw
|
||||||
|
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
{isRefreshing ? 'Syncing...' : 'Sync'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{walletInfo && (
|
||||||
|
<>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-2'>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||||
|
<ShieldCheck className='text-primary h-5 w-5' />
|
||||||
|
Status & Identity
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-sm'>Type</span>
|
||||||
|
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
|
||||||
|
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{walletInfo.parentKey && (
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<span className='text-muted-foreground text-xs tracking-wider uppercase'>
|
||||||
|
Parent Key
|
||||||
|
</span>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
|
||||||
|
{walletInfo.parentKey}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='h-8 w-8'
|
||||||
|
onClick={() => handleCopy(walletInfo.parentKey!)}
|
||||||
|
>
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
Validity
|
||||||
|
</span>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{formatDate(walletInfo.validityDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
Spendable Balance
|
||||||
|
</span>
|
||||||
|
<span className='text-primary font-mono text-sm font-medium'>
|
||||||
|
{formatSats(walletInfo.balanceMsats)} sats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-2'>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||||
|
<History className='text-primary h-5 w-5' />
|
||||||
|
Consumption
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
Total Requests
|
||||||
|
</span>
|
||||||
|
<span className='font-mono text-sm font-medium'>
|
||||||
|
{walletInfo.totalRequests}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
Total Spent
|
||||||
|
</span>
|
||||||
|
<div className='text-right'>
|
||||||
|
<p className='font-mono text-sm font-medium'>
|
||||||
|
{formatSats(walletInfo.totalSpent)} sats
|
||||||
|
</p>
|
||||||
|
<p className='text-muted-foreground font-mono text-[0.6rem]'>
|
||||||
|
{formatMsats(walletInfo.totalSpent)} msats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{walletInfo.balanceLimit !== null && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
Spend Limit
|
||||||
|
</span>
|
||||||
|
<span className='font-mono text-sm font-medium'>
|
||||||
|
{formatSats(walletInfo.balanceLimit)} sats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{walletInfo.balanceLimitReset && (
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
Reset Policy
|
||||||
|
</span>
|
||||||
|
<Badge variant='outline' className='capitalize'>
|
||||||
|
{walletInfo.balanceLimitReset}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!walletInfo.isChild &&
|
||||||
|
walletInfo.childKeys &&
|
||||||
|
walletInfo.childKeys.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||||
|
<Users className='text-primary h-5 w-5' />
|
||||||
|
Child Keys ({walletInfo.childKeys.length})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Secondary keys using this account's balance
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{walletInfo.childKeys.map((ck) => (
|
||||||
|
<div
|
||||||
|
key={ck.api_key}
|
||||||
|
className='space-y-3 rounded-lg border p-4'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-4'>
|
||||||
|
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
|
||||||
|
{ck.api_key}
|
||||||
|
</code>
|
||||||
|
<div className='flex gap-1'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='h-8 w-8'
|
||||||
|
onClick={() => handleCopy(ck.api_key)}
|
||||||
|
>
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='text-destructive h-8 w-8'
|
||||||
|
title='Reset consumption'
|
||||||
|
disabled={isResetting === ck.api_key}
|
||||||
|
onClick={() => handleResetSpent(ck.api_key)}
|
||||||
|
>
|
||||||
|
{isResetting === ck.api_key ? (
|
||||||
|
<RefreshCcw className='h-4 w-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-4 text-xs sm:grid-cols-5'>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||||
|
Requests
|
||||||
|
</p>
|
||||||
|
<p className='font-mono font-medium'>
|
||||||
|
{ck.total_requests}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||||
|
Spent
|
||||||
|
</p>
|
||||||
|
<p className='font-mono font-medium'>
|
||||||
|
{formatSats(ck.total_spent)} sats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||||
|
Limit
|
||||||
|
</p>
|
||||||
|
<p className='font-mono font-medium'>
|
||||||
|
{ck.balance_limit
|
||||||
|
? `${formatSats(ck.balance_limit)} sats`
|
||||||
|
: 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||||
|
Policy
|
||||||
|
</p>
|
||||||
|
<p className='font-medium capitalize'>
|
||||||
|
{ck.balance_limit_reset || 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||||
|
Expires
|
||||||
|
</p>
|
||||||
|
<p className='font-medium'>
|
||||||
|
{formatDate(ck.validity_date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className='text-muted-foreground'
|
||||||
|
>
|
||||||
|
<RefreshCcw
|
||||||
|
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
Last synced: {new Date().toLocaleTimeString()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,12 +10,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { KeyOptions } from '@/components/key-options';
|
||||||
type WalletSnapshot = {
|
import type { WalletSnapshot } from './key-info-details';
|
||||||
apiKey: string;
|
|
||||||
balanceMsats: number;
|
|
||||||
reservedMsats: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LightningInvoice = {
|
type LightningInvoice = {
|
||||||
invoice_id: string;
|
invoice_id: string;
|
||||||
@@ -89,7 +85,10 @@ export function LightningPaymentWorkflow({
|
|||||||
const [isTopupping, setIsTopupping] = useState(false);
|
const [isTopupping, setIsTopupping] = useState(false);
|
||||||
const [isRecovering, setIsRecovering] = 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 [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
||||||
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
|
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
|
||||||
|
|
||||||
@@ -166,13 +165,29 @@ export function LightningPaymentWorkflow({
|
|||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
|
||||||
try {
|
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`, {
|
const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
amount_sats: amount,
|
|
||||||
purpose: 'create',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -195,6 +210,13 @@ export function LightningPaymentWorkflow({
|
|||||||
apiKey: status.api_key,
|
apiKey: status.api_key,
|
||||||
balanceMsats: status.amount_sats * 1000,
|
balanceMsats: status.amount_sats * 1000,
|
||||||
reservedMsats: 0,
|
reservedMsats: 0,
|
||||||
|
isChild: false,
|
||||||
|
parentKey: null,
|
||||||
|
totalRequests: 0,
|
||||||
|
totalSpent: 0,
|
||||||
|
balanceLimit: null,
|
||||||
|
balanceLimitReset: null,
|
||||||
|
validityDate: null,
|
||||||
};
|
};
|
||||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||||
setCreatedApiKey(status.api_key);
|
setCreatedApiKey(status.api_key);
|
||||||
@@ -214,7 +236,15 @@ export function LightningPaymentWorkflow({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
}, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated]);
|
}, [
|
||||||
|
createAmount,
|
||||||
|
baseUrl,
|
||||||
|
pollInvoiceStatus,
|
||||||
|
onApiKeyCreated,
|
||||||
|
balanceLimit,
|
||||||
|
balanceLimitReset,
|
||||||
|
validityDate,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleTopupInvoice = useCallback(async (): Promise<void> => {
|
const handleTopupInvoice = useCallback(async (): Promise<void> => {
|
||||||
const amount = parseInt(topupAmount);
|
const amount = parseInt(topupAmount);
|
||||||
@@ -261,6 +291,13 @@ export function LightningPaymentWorkflow({
|
|||||||
apiKey: status.api_key,
|
apiKey: status.api_key,
|
||||||
balanceMsats: status.amount_sats * 1000,
|
balanceMsats: status.amount_sats * 1000,
|
||||||
reservedMsats: 0,
|
reservedMsats: 0,
|
||||||
|
isChild: false,
|
||||||
|
parentKey: null,
|
||||||
|
totalRequests: 0,
|
||||||
|
totalSpent: 0,
|
||||||
|
balanceLimit: null,
|
||||||
|
balanceLimitReset: null,
|
||||||
|
validityDate: null,
|
||||||
};
|
};
|
||||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||||
setTopupApiKeyResult(status.api_key);
|
setTopupApiKeyResult(status.api_key);
|
||||||
@@ -315,6 +352,13 @@ export function LightningPaymentWorkflow({
|
|||||||
apiKey: status.api_key,
|
apiKey: status.api_key,
|
||||||
balanceMsats: status.amount_sats * 1000,
|
balanceMsats: status.amount_sats * 1000,
|
||||||
reservedMsats: 0,
|
reservedMsats: 0,
|
||||||
|
isChild: false,
|
||||||
|
parentKey: null,
|
||||||
|
totalRequests: 0,
|
||||||
|
totalSpent: 0,
|
||||||
|
balanceLimit: null,
|
||||||
|
balanceLimitReset: null,
|
||||||
|
validityDate: null,
|
||||||
};
|
};
|
||||||
onApiKeyCreated?.(status.api_key, walletInfo);
|
onApiKeyCreated?.(status.api_key, walletInfo);
|
||||||
setRecoveredApiKey(status.api_key);
|
setRecoveredApiKey(status.api_key);
|
||||||
@@ -333,14 +377,13 @@ export function LightningPaymentWorkflow({
|
|||||||
}
|
}
|
||||||
}, [recoverInvoice, baseUrl, onApiKeyCreated]);
|
}, [recoverInvoice, baseUrl, onApiKeyCreated]);
|
||||||
|
|
||||||
const showCreateDetails =
|
|
||||||
hasInteractedCreate || createAmount.trim().length > 0;
|
|
||||||
const showTopupDetails =
|
const showTopupDetails =
|
||||||
hasInteractedTopup ||
|
hasInteractedTopup ||
|
||||||
topupAmount.trim().length > 0 ||
|
topupAmount.trim().length > 0 ||
|
||||||
topupApiKey.trim().length > 0;
|
topupApiKey.trim().length > 0;
|
||||||
const showRecoverDetails =
|
const showRecoverDetails =
|
||||||
hasInteractedRecover || recoverInvoice.trim().length > 0;
|
hasInteractedRecover || recoverInvoice.trim().length > 0;
|
||||||
|
const showCreateDetails = createAmount.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -367,9 +410,18 @@ export function LightningPaymentWorkflow({
|
|||||||
onChange={(event) => setCreateAmount(event.target.value)}
|
onChange={(event) => setCreateAmount(event.target.value)}
|
||||||
placeholder='Amount in sats (e.g., 1000)'
|
placeholder='Amount in sats (e.g., 1000)'
|
||||||
className='text-sm'
|
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'>
|
<div className='space-y-3'>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateInvoice}
|
onClick={handleCreateInvoice}
|
||||||
@@ -473,7 +525,7 @@ export function LightningPaymentWorkflow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export function TemporaryBalances({
|
|||||||
let totalRequests = 0;
|
let totalRequests = 0;
|
||||||
|
|
||||||
balances.forEach((balance) => {
|
balances.forEach((balance) => {
|
||||||
totalBalance += balance.balance || 0;
|
// Only count parents for total balance to avoid double counting
|
||||||
|
// since child keys use parent balance
|
||||||
|
if (!balance.parent_key_hash) {
|
||||||
|
totalBalance += balance.balance || 0;
|
||||||
|
}
|
||||||
totalSpent += balance.total_spent || 0;
|
totalSpent += balance.total_spent || 0;
|
||||||
totalRequests += balance.total_requests || 0;
|
totalRequests += balance.total_requests || 0;
|
||||||
});
|
});
|
||||||
@@ -72,6 +76,34 @@ export function TemporaryBalances({
|
|||||||
? calculateTotals(data)
|
? calculateTotals(data)
|
||||||
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
||||||
|
|
||||||
|
// Group parents and children
|
||||||
|
const hierarchicalData = (() => {
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
const parents = filteredData.filter((item) => !item.parent_key_hash);
|
||||||
|
const result: (TemporaryBalance & { isChild?: boolean })[] = [];
|
||||||
|
|
||||||
|
parents.forEach((parent) => {
|
||||||
|
result.push(parent);
|
||||||
|
const children = data.filter(
|
||||||
|
(item) => item.parent_key_hash === parent.hashed_key
|
||||||
|
);
|
||||||
|
children.forEach((child) => {
|
||||||
|
result.push({ ...child, isChild: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add children whose parents didn't match the search or aren't in the list
|
||||||
|
const orphans = filteredData.filter(
|
||||||
|
(item) =>
|
||||||
|
item.parent_key_hash &&
|
||||||
|
!result.some((r) => r.hashed_key === item.hashed_key)
|
||||||
|
);
|
||||||
|
result.push(...orphans.map((o) => ({ ...o, isChild: true })));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className='h-full w-full shadow-sm'>
|
<Card className='h-full w-full shadow-sm'>
|
||||||
@@ -182,22 +214,37 @@ export function TemporaryBalances({
|
|||||||
<div className='text-right'>Expiry Time</div>
|
<div className='text-right'>Expiry Time</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredData.length > 0 ? (
|
{hierarchicalData.length > 0 ? (
|
||||||
filteredData.map((balance, index) => (
|
hierarchicalData.map((balance, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
'hover:bg-muted/50 border-t p-3 text-sm transition-colors',
|
'hover:bg-muted/50 border-t p-3 text-sm transition-colors',
|
||||||
balance.balance === 0 && 'opacity-60'
|
balance.balance === 0 &&
|
||||||
|
!balance.isChild &&
|
||||||
|
'opacity-60',
|
||||||
|
balance.isChild &&
|
||||||
|
'ml-4 border-l-2 border-l-blue-200 bg-blue-50/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Desktop Layout */}
|
{/* Desktop Layout */}
|
||||||
<div className='hidden grid-cols-6 gap-2 md:grid'>
|
<div className='hidden grid-cols-6 gap-2 md:grid'>
|
||||||
<div className='max-w-32 truncate font-mono text-xs break-all'>
|
<div className='flex max-w-48 items-center gap-2 truncate font-mono text-xs break-all'>
|
||||||
|
{balance.isChild && (
|
||||||
|
<span className='rounded bg-blue-100 px-1 py-0.5 text-[10px] font-bold text-blue-700 uppercase'>
|
||||||
|
Child
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{balance.hashed_key}
|
{balance.hashed_key}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-right font-mono'>
|
<div className='text-right font-mono'>
|
||||||
{formatBalance(balance.balance)}
|
{balance.isChild ? (
|
||||||
|
<span className='text-muted-foreground italic'>
|
||||||
|
(Parent)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatBalance(balance.balance)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-right font-mono'>
|
<div className='text-right font-mono'>
|
||||||
{formatBalance(balance.total_spent)}
|
{formatBalance(balance.total_spent)}
|
||||||
@@ -226,13 +273,20 @@ export function TemporaryBalances({
|
|||||||
|
|
||||||
{/* Mobile Layout */}
|
{/* Mobile Layout */}
|
||||||
<div className='space-y-3 md:hidden'>
|
<div className='space-y-3 md:hidden'>
|
||||||
<div className='space-y-1'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='text-muted-foreground text-xs font-medium'>
|
<div className='space-y-1'>
|
||||||
Key
|
<span className='text-muted-foreground text-xs font-medium'>
|
||||||
</span>
|
{balance.isChild ? 'Child Key' : 'Key'}
|
||||||
<div className='font-mono text-xs break-all'>
|
</span>
|
||||||
{balance.hashed_key}
|
<div className='font-mono text-xs break-all'>
|
||||||
|
{balance.hashed_key}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{balance.isChild && (
|
||||||
|
<span className='rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase'>
|
||||||
|
Child
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
@@ -241,7 +295,13 @@ export function TemporaryBalances({
|
|||||||
Balance
|
Balance
|
||||||
</div>
|
</div>
|
||||||
<div className='truncate font-mono text-sm'>
|
<div className='truncate font-mono text-sm'>
|
||||||
{formatBalance(balance.balance)}
|
{balance.isChild ? (
|
||||||
|
<span className='text-muted-foreground text-xs italic'>
|
||||||
|
(Uses Parent)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatBalance(balance.balance)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
|
|||||||
@@ -139,18 +139,26 @@ export class AdminService {
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
if (!pricing) return pricing;
|
if (!pricing) return pricing;
|
||||||
const result = { ...pricing };
|
const result = { ...pricing };
|
||||||
if (typeof result.prompt === 'number') {
|
|
||||||
result.prompt = result.prompt * 1000000;
|
// Only prompt and completion are per-token and need scaling to per-1M
|
||||||
}
|
const convertField = (field: string) => {
|
||||||
if (typeof result.completion === 'number') {
|
const val = result[field];
|
||||||
result.completion = result.completion * 1000000;
|
if (val !== undefined && val !== null) {
|
||||||
}
|
const num = typeof val === 'string' ? parseFloat(val) : (val as number);
|
||||||
if (typeof result.request === 'number') {
|
if (!isNaN(num)) {
|
||||||
result.request = result.request * 1000000;
|
// Multiply by 1M and round to avoid floating point artifacts (e.g. 0.40399999999999997)
|
||||||
}
|
// 9 decimals is plenty for USD/1M tokens (0.000000001)
|
||||||
if (typeof result.image === 'number') {
|
result[field] = parseFloat((num * 1000000).toFixed(9));
|
||||||
result.image = result.image * 1000000;
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
convertField('prompt');
|
||||||
|
convertField('completion');
|
||||||
|
|
||||||
|
// Other fields (request, image, etc.) are already flat fees (per item)
|
||||||
|
// so we do NOT scale them.
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,18 +167,23 @@ export class AdminService {
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
if (!pricing) return pricing;
|
if (!pricing) return pricing;
|
||||||
const result = { ...pricing };
|
const result = { ...pricing };
|
||||||
if (typeof result.prompt === 'number') {
|
|
||||||
result.prompt = result.prompt / 1000000;
|
// Only prompt and completion are per-1M in UI and need scaling down to per-token
|
||||||
}
|
const convertField = (field: string) => {
|
||||||
if (typeof result.completion === 'number') {
|
const val = result[field];
|
||||||
result.completion = result.completion / 1000000;
|
if (val !== undefined && val !== null) {
|
||||||
}
|
const num = typeof val === 'string' ? parseFloat(val) : (val as number);
|
||||||
if (typeof result.request === 'number') {
|
if (!isNaN(num)) {
|
||||||
result.request = result.request / 1000000;
|
result[field] = num / 1000000;
|
||||||
}
|
}
|
||||||
if (typeof result.image === 'number') {
|
}
|
||||||
result.image = result.image / 1000000;
|
};
|
||||||
}
|
|
||||||
|
convertField('prompt');
|
||||||
|
convertField('completion');
|
||||||
|
|
||||||
|
// Other fields stay as flat fees
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +304,19 @@ export class AdminService {
|
|||||||
const data = await apiClient.get<ProviderModels>(
|
const data = await apiClient.get<ProviderModels>(
|
||||||
`/admin/api/upstream-providers/${providerId}/models`
|
`/admin/api/upstream-providers/${providerId}/models`
|
||||||
);
|
);
|
||||||
return data;
|
|
||||||
|
// Convert pricing for all models in the list so the UI receives "per 1M tokens" values
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
db_models: data.db_models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
pricing: this.convertPricingToPerMillionTokens(m.pricing),
|
||||||
|
})),
|
||||||
|
remote_models: data.remote_models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
pricing: this.convertPricingToPerMillionTokens(m.pricing),
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createProviderModel(
|
static async createProviderModel(
|
||||||
@@ -316,6 +341,23 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async batchOverrideProviderModels(
|
||||||
|
providerId: number,
|
||||||
|
models: AdminModel[]
|
||||||
|
): Promise<{ ok: boolean; count: number; message: string }> {
|
||||||
|
const payload = {
|
||||||
|
models: models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
pricing: this.convertPricingToPerToken(m.pricing),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return await apiClient.post<{
|
||||||
|
ok: boolean;
|
||||||
|
count: number;
|
||||||
|
message: string;
|
||||||
|
}>(`/admin/api/upstream-providers/${providerId}/batch-override`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
static async getProviderModel(
|
static async getProviderModel(
|
||||||
providerId: number,
|
providerId: number,
|
||||||
modelId: string
|
modelId: string
|
||||||
@@ -498,8 +540,8 @@ export class AdminService {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const pricing = {
|
const pricing = {
|
||||||
prompt: (data.input_cost as number) / 1000000,
|
prompt: data.input_cost as number,
|
||||||
completion: (data.output_cost as number) / 1000000,
|
completion: data.output_cost as number,
|
||||||
request: (data.min_cost_per_request as number) || 0,
|
request: (data.min_cost_per_request as number) || 0,
|
||||||
image: 0,
|
image: 0,
|
||||||
web_search: 0,
|
web_search: 0,
|
||||||
@@ -553,8 +595,8 @@ export class AdminService {
|
|||||||
const existingModel = await this.getModel(modelId, providerId);
|
const existingModel = await this.getModel(modelId, providerId);
|
||||||
|
|
||||||
const pricing = {
|
const pricing = {
|
||||||
prompt: (data.input_cost as number) / 1000000,
|
prompt: data.input_cost as number,
|
||||||
completion: (data.output_cost as number) / 1000000,
|
completion: data.output_cost as number,
|
||||||
request: (data.min_cost_per_request as number) || 0,
|
request: (data.min_cost_per_request as number) || 0,
|
||||||
image: 0,
|
image: 0,
|
||||||
web_search: 0,
|
web_search: 0,
|
||||||
@@ -926,6 +968,7 @@ export const TemporaryBalanceSchema = z.object({
|
|||||||
total_requests: z.number(),
|
total_requests: z.number(),
|
||||||
refund_address: z.string().nullable(),
|
refund_address: z.string().nullable(),
|
||||||
key_expiry_time: z.number().nullable(),
|
key_expiry_time: z.number().nullable(),
|
||||||
|
parent_key_hash: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||||
|
|||||||
@@ -1,30 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { apiClient } from '../client';
|
|
||||||
import { ConfigurationService } from './configuration';
|
import { ConfigurationService } from './configuration';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
|
||||||
username: z.string().optional(),
|
|
||||||
password: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LoginRequest = z.infer<typeof loginSchema>;
|
|
||||||
|
|
||||||
export const loginResponseSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LoginResponse = z.infer<typeof loginResponseSchema>;
|
|
||||||
|
|
||||||
export async function login(data: LoginRequest): Promise<LoginResponse> {
|
|
||||||
try {
|
|
||||||
return await apiClient.post<LoginResponse>('/api/login', data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const adminLoginSchema = z.object({
|
export const adminLoginSchema = z.object({
|
||||||
password: z.string().min(1, 'Password is required'),
|
password: z.string().min(1, 'Password is required'),
|
||||||
});
|
});
|
||||||
@@ -91,40 +68,3 @@ export async function adminLogout(): Promise<void> {
|
|||||||
ConfigurationService.clearToken();
|
ConfigurationService.clearToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registerSchema = z.object({
|
|
||||||
npub: z.string().min(10, { message: 'must have at least 10 character' }),
|
|
||||||
name: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RegisterRequest = z.infer<typeof registerSchema>;
|
|
||||||
export type SchemaRegisterProps = z.infer<typeof registerSchema>;
|
|
||||||
|
|
||||||
export const registerResponseSchema = z.object({
|
|
||||||
user_id: z.string(),
|
|
||||||
theme: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RegisterResponse = z.infer<typeof registerResponseSchema>;
|
|
||||||
|
|
||||||
export async function register(
|
|
||||||
data: RegisterRequest
|
|
||||||
): Promise<RegisterResponse> {
|
|
||||||
try {
|
|
||||||
return await apiClient.post<RegisterResponse>('/api/register', data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const registerUser = register;
|
|
||||||
|
|
||||||
export async function getUserSettings(): Promise<{ id: string }> {
|
|
||||||
try {
|
|
||||||
return await apiClient.get<{ id: string }>('/api/user/settings');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching user settings:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+100
-11
@@ -29,6 +29,28 @@ export type RedeemTokenResponse = z.infer<typeof RedeemTokenResponseSchema>;
|
|||||||
export type SendTokenRequest = z.infer<typeof SendTokenRequestSchema>;
|
export type SendTokenRequest = z.infer<typeof SendTokenRequestSchema>;
|
||||||
export type SendTokenResponse = z.infer<typeof SendTokenResponseSchema>;
|
export type SendTokenResponse = z.infer<typeof SendTokenResponseSchema>;
|
||||||
|
|
||||||
|
export interface BalanceDetail {
|
||||||
|
mint_url: string;
|
||||||
|
unit: string;
|
||||||
|
wallet_balance: number;
|
||||||
|
user_balance: number;
|
||||||
|
owner_balance: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChildKeyResponse {
|
||||||
|
api_keys: string[];
|
||||||
|
count: number;
|
||||||
|
cost_msats: number;
|
||||||
|
cost_sats: number;
|
||||||
|
parent_balance: number;
|
||||||
|
parent_balance_sats: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class WalletService {
|
export class WalletService {
|
||||||
static async redeemToken(token: string): Promise<RedeemTokenResponse> {
|
static async redeemToken(token: string): Promise<RedeemTokenResponse> {
|
||||||
try {
|
try {
|
||||||
@@ -108,17 +130,84 @@ export class WalletService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export interface BalanceDetail {
|
static async createChildKey(
|
||||||
mint_url: string;
|
baseUrl?: string,
|
||||||
unit: string;
|
apiKey?: string,
|
||||||
wallet_balance: number;
|
count: number = 1,
|
||||||
user_balance: number;
|
balanceLimit?: number,
|
||||||
owner_balance: number;
|
balanceLimitReset?: string,
|
||||||
error?: string;
|
validityDate?: number
|
||||||
}
|
): Promise<CreateChildKeyResponse> {
|
||||||
|
try {
|
||||||
|
if (baseUrl && apiKey) {
|
||||||
|
const response = await fetch(`${baseUrl}/v1/balance/child-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
count,
|
||||||
|
balance_limit: balanceLimit,
|
||||||
|
balance_limit_reset: balanceLimitReset,
|
||||||
|
validity_date: validityDate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export interface WithdrawResponse {
|
if (!response.ok) {
|
||||||
token: string;
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || 'Failed to create child key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as CreateChildKeyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await apiClient.post<CreateChildKeyResponse>(
|
||||||
|
'/v1/balance/child-key',
|
||||||
|
{
|
||||||
|
count,
|
||||||
|
balance_limit: balanceLimit,
|
||||||
|
balance_limit_reset: balanceLimitReset,
|
||||||
|
validity_date: validityDate,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating child key:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async resetChildKeySpent(
|
||||||
|
baseUrl: string | undefined,
|
||||||
|
parentKey: string,
|
||||||
|
childKey: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const url = baseUrl
|
||||||
|
? `${baseUrl}/v1/balance/child-key/reset`
|
||||||
|
: '/v1/balance/child-key/reset';
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${parentKey}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ child_key: childKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || 'Failed to reset child key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting child key:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user