Compare commits

...

140 Commits

Author SHA1 Message Date
Evan Yang
20b4c3a641 Restore analytics dashboard and polish mobile shell 2026-03-07 18:52:17 +08:00
Evan Yang
88f7ee734f Fix dashboard auth loading state on split UI branch 2026-03-07 18:16:29 +08:00
Evan Yang
326a0086b7 Fix chart container sizing warnings 2026-03-07 18:15:44 +08:00
Evan Yang
eb46651373 Fix UI formatting for CI 2026-03-07 18:15:44 +08:00
Evan Yang
226188e22d Refine admin UI and models routing 2026-03-07 18:15:44 +08:00
Evan Yang
89fc48eea2 Refine model pricing and mobile list layout 2026-03-07 18:15:29 +08:00
Evan Yang
f66d98e6b5 chore(release): bump version to 0.4.0 2026-03-07 18:15:29 +08:00
Evan Yang
cfea4f9cd1 Update Node.js version in GitHub Actions workflow from 18 to 20 2026-03-07 18:15:29 +08:00
Evan Yang
c74a877ab5 Fix build and formatting 2026-03-07 18:15:29 +08:00
Evan Yang
83a76e86fe Update dashboard UI/UX 2026-03-07 18:15:29 +08:00
9qeklajc
5788d63892 Merge pull request #394 from Routstr/fix-refund-token
fix refund token multiple times
2026-03-06 23:06:14 +01:00
9qeklajc
d0c7cc6bd9 fix refund token multiple times 2026-03-06 23:02:47 +01:00
9qeklajc
40096867d9 Merge pull request #390 from Routstr/fix-negative-reserve
fix negative reserve balance
2026-03-06 18:02:13 +01:00
9qeklajc
e002c0b66f clean up 2026-03-06 17:59:49 +01:00
9qeklajc
608549d051 Merge pull request #357 from Routstr/fix/azure-kimi-routing-v040-on-v0.4.0
Fix Azure Kimi routing and DB override model mapping
2026-03-06 16:58:57 +01:00
9qeklajc
72f389c8d2 Merge pull request #354 from Routstr/fix/azure-remote-model-filter-by-id-v040
fix(admin): filter provider remote models by id
2026-03-06 16:58:02 +01:00
9qeklajc
4bce04ada4 Merge pull request #391 from Routstr/fix/cashu-402-not-wrapped
Fix Cashu insufficient-balance responses being wrapped as 401
2026-03-03 23:56:20 +01:00
9qeklajc
f1e2448620 Merge pull request #388 from Routstr/fix/skip-auth-preflight-balance
Skip preflight balance checks for Authorization tokens
2026-03-03 23:53:41 +01:00
redshift
28340a152c fix cashu auth to preserve insufficient balance status 2026-03-03 22:20:42 +00:00
redshift
e28f6118e7 Merge v0.4.0 into fix/skip-auth-preflight-balance 2026-03-03 15:03:31 +00:00
9qeklajc
9fb6f54d12 fix negative reserve balance 2026-03-03 15:29:07 +01:00
9qeklajc
3cb8d7b5dd Merge pull request #377 from Routstr/split-all
split all spaces
2026-03-03 00:47:54 +01:00
9qeklajc
ede076b881 Merge pull request #374 from Routstr/add-cost-details
Add cost details
2026-03-03 00:47:40 +01:00
redshift
449a0951f9 skip preflight balance checks for Authorization tokens 2026-03-02 14:39:55 +00:00
9qeklajc
8c9ede2272 split all spaces 2026-02-25 17:57:17 +01:00
redshift
e794b09614 Add sats cost and remaining balance to response 2026-02-22 15:44:37 +01:00
9qeklajc
24f6519267 Merge pull request #372 from Routstr/fix-migration
quick fix for failed migration (first time setup)
2026-02-19 13:37:21 +01:00
9qeklajc
f57beb6411 quick fix for failed migration 2026-02-19 13:27:09 +01:00
9qeklajc
3e41e59a1d Merge pull request #371 from Routstr/add-reverted-changes
revert missing check
2026-02-19 12:54:28 +01:00
9qeklajc
002d750830 revert missing check 2026-02-19 12:37:41 +01:00
9qeklajc
8c2eb55760 Merge branch 'main' into v0.4.0
# Conflicts:
#	docs/provider/quickstart.md
2026-02-18 23:50:09 +01:00
9qeklajc
6fbd479bdc Merge pull request #367 from Routstr/fixed-max-cost-discount-bug
Fixed max cost discount bug
2026-02-16 21:34:12 +01:00
9qeklajc
f67c26935a Merge pull request #360 from Routstr/custom-models-fix
Custom models were only showing up in the DB but now in the v1/models output
2026-02-16 21:31:56 +01:00
red
9ce91f58a9 fxied the bug by calculating the same way as _calculate_usd_max_costs in models.py 2026-02-16 09:18:12 +00:00
red
bb82361434 fixed include disabledd 2026-02-16 09:14:35 +00:00
9qeklajc
5f1d67e87e Merge pull request #365 from Routstr/child-key-details
Child key details
2026-02-15 17:28:57 +01:00
9qeklajc
b2106ad1e6 fix build 2026-02-15 17:23:16 +01:00
9qeklajc
709a4ba0dc lint 2026-02-15 17:16:10 +01:00
9qeklajc
21f421b212 fmt 2026-02-15 17:09:46 +01:00
9qeklajc
b3c5e4cbf6 add doc 2026-02-15 17:06:07 +01:00
9qeklajc
1d95379328 add child keys details to view 2026-02-15 17:06:01 +01:00
9qeklajc
48529f672a Merge pull request #364 from Routstr/update-main-doc
add missing info
2026-02-13 22:29:09 +01:00
9qeklajc
030c2f65e4 add missing info 2026-02-13 22:22:41 +01:00
9qeklajc
3510402af2 Merge pull request #363 from Routstr/update-main-doc
update doc
2026-02-13 21:26:26 +01:00
9qeklajc
5f376d716d clean up 2026-02-13 21:15:33 +01:00
9qeklajc
d889274f84 update doc 2026-02-12 23:25:59 +01:00
9qeklajc
0c0f19d854 Merge pull request #362 from Routstr/update-main-doc
Update main doc
2026-02-12 21:23:53 +01:00
9qeklajc
b61bffc666 Merge pull request #361 from Routstr/update-docs
update doc
2026-02-12 21:20:17 +01:00
9qeklajc
af658136d4 add docker file 2026-02-12 21:19:19 +01:00
9qeklajc
8973627b5f update doc 2026-02-12 21:19:12 +01:00
9qeklajc
25f427033a add docker file 2026-02-12 21:18:22 +01:00
9qeklajc
d3dd8318e4 update doc 2026-02-12 21:15:44 +01:00
redshift
52c7f17215 fixed build errors 2 2026-02-11 02:54:16 +00:00
redshift
2c03302055 fixed build errors 2026-02-11 02:32:21 +00:00
redshift
c3221f2a31 Fixed custom models not showing up in the v1/models output 2026-02-11 02:26:24 +00:00
Evan Yang
ce9834d7ec fix: harden azure routing and model override mapping 2026-02-10 19:15:25 +08:00
9qeklajc
6ebe73f2f7 Fix Azure Kimi routing and DB override model mapping 2026-02-10 07:32:54 +00:00
Evan Yang
98aecb08f9 fix(admin): filter provider remote models by id 2026-02-10 01:04:14 +08:00
9qeklajc
58fa063c6b Merge pull request #352 from Routstr/fix-pyament-finalization
enforce payment finalization
2026-02-08 23:20:20 +01:00
9qeklajc
7c94f60797 Merge pull request #351 from Routstr/child-key-expiration
Child key expiration
2026-02-08 23:20:10 +01:00
9qeklajc
4cb4c6dfec fix test 2026-02-08 23:06:48 +01:00
9qeklajc
512b686e5f fmt 2026-02-08 22:56:38 +01:00
9qeklajc
f495a10eeb keys with different config and better reset 2026-02-08 22:54:59 +01:00
9qeklajc
d1692edb63 no balance limit for parent key 2026-02-07 00:13:57 +01:00
9qeklajc
8f81bcd2fc fix do not remove key after refund 2026-02-05 18:48:46 +01:00
9qeklajc
6a5ed9d063 fmt 2026-02-05 01:20:10 +01:00
9qeklajc
b9890e6ad5 fmt 2026-02-05 01:19:11 +01:00
9qeklajc
e4b8293d41 lint 2026-02-05 01:03:25 +01:00
9qeklajc
ba5f9fc181 improvve key logic 2026-02-04 23:10:50 +01:00
9qeklajc
795fff61e0 child-key-expiration 2026-02-02 22:29:54 +01:00
shroominic
f9bfd4f0d2 routstr/v0.3.0
v0.3.0
2026-02-02 16:44:31 +08:00
9qeklajc
5683382ada Merge pull request #341 from Routstr/refactor/remove-unused-code
refactor: remove unused code
2026-02-01 23:09:33 +01:00
9qeklajc
c92372dafd Merge pull request #342 from Routstr/refactor/remove-deprecated-admin-html
refactor: remove deprecated admin html
2026-02-01 23:09:06 +01:00
9qeklajc
b58dd78fde Merge pull request #339 from Routstr/refactor/nostr-discovery
refactor: nostr logic
2026-02-01 23:06:09 +01:00
9qeklajc
4c31bf9767 enforce payment finalization 2026-02-01 23:01:47 +01:00
shroominic
42efa3c1ba Merge pull request #337 from Routstr/no-default-next-public-api-url
No default next public api url
2026-01-31 07:42:14 +08:00
shroominic
74b1d39d5c Merge pull request #332 from Routstr/missing-delete-button
Missing delete button
2026-01-31 07:42:04 +08:00
shroominic
4df4976f44 Merge pull request #331 from Routstr/batch-override-models
batch override models
2026-01-31 07:41:54 +08:00
Shroominic
4aa57959bf prettier 2026-01-31 07:38:05 +08:00
Shroominic
1af39f043f fix tests 2026-01-31 07:37:39 +08:00
Shroominic
1751cd3b47 remove deprecated htmx endpoints 2026-01-31 07:23:58 +08:00
Shroominic
b1facd58d5 rm test checking unused functions 2026-01-31 07:21:44 +08:00
Shroominic
6288d6fef7 more unused code lmao 2026-01-31 07:09:04 +08:00
Shroominic
a1223ad610 rm unused code lol 2026-01-31 07:08:54 +08:00
Shroominic
c75f170ed0 Refactor: Move Discovery and Nostr logic to routstr/nostr package 2026-01-31 06:59:27 +08:00
Shroominic
c6e401c3f6 Merge branch 'main' into v0.3.0
# Conflicts:
#	routstr/proxy.py
2026-01-30 10:54:35 +08:00
shroominic
1b3b206a20 Merge pull request #336 from Routstr/prevent-payout-race-contition
prevent payout race condition
2026-01-30 10:49:44 +08:00
Shroominic
248937e05f comment out NEXT_PUBLIC_API_URL by default 2026-01-30 10:48:37 +08:00
shroominic
b9b477e5eb Merge pull request #335 from Routstr/fix-recurring-payout-error
Fix recurring payout error
2026-01-30 10:06:37 +08:00
Shroominic
ace8cf960c prevent payout race condition 2026-01-30 10:05:37 +08:00
Shroominic
3396e0cd47 fix recurring payout error 2026-01-30 09:59:59 +08:00
Shroominic
31898192b2 added missing delete button for custom models 2026-01-29 13:05:56 +08:00
Shroominic
e50facc835 prettier 2026-01-29 11:05:46 +08:00
Shroominic
55dc485705 Merge branch 'v0.3.0' into batch-override-models 2026-01-29 11:03:43 +08:00
shroominic
855d60b4a5 Merge pull request #330 from Routstr/fix-provider-model-pricing
fix provider model pricing
2026-01-29 10:58:27 +08:00
shroominic
27ace348b5 Merge pull request #329 from Routstr/fix-multiple-custom-models
fix: unable to add multiple custom models
2026-01-29 10:58:14 +08:00
Shroominic
80559a57d5 fix linting 2026-01-29 10:55:07 +08:00
Shroominic
8e9f6647e7 batch override models 2026-01-29 09:08:26 +08:00
Shroominic
73e3d34623 fix provider model pricing 2026-01-29 08:50:17 +08:00
Shroominic
c8f8857f03 fckng prettier again 2026-01-28 18:12:54 +08:00
Shroominic
c9650441bb fix build error 2026-01-28 18:11:07 +08:00
Shroominic
5ce9c2217f fix fmt 2026-01-28 18:04:56 +08:00
Shroominic
89b8488ab8 prettier 2026-01-28 17:58:24 +08:00
Shroominic
2ee917fa31 fix not being able to add multiple custom models when provider does not have Provided Models 2026-01-28 17:55:06 +08:00
9qeklajc
5e12a7e92d Merge pull request #327 from Routstr/fix/multi-provider-base-url
Allow multiple providers per base URL
2026-01-27 09:24:20 +01:00
9qeklajc
1d043cd98d lint 2026-01-27 09:22:07 +01:00
9qeklajc
39e0959fcd Merge pull request #326 from Routstr/feat/preset-selector-override-modal
Add preset selector to model override modal
2026-01-27 09:19:56 +01:00
9qeklajc
29be9d5b9c Merge pull request #323 from Routstr/create-child-key-ui
create child keys within the dashboard
2026-01-27 09:07:05 +01:00
9qeklajc
1ebb7d71e1 fix test 2026-01-27 08:58:46 +01:00
9qeklajc
bbf1e65a5d clean up & add docs 2026-01-26 20:44:23 +01:00
Shroominic
51c3e5dcd7 fix migrations 2026-01-25 22:58:36 +08:00
Shroominic
788075f656 fix db migration 2026-01-25 22:44:13 +08:00
Shroominic
0bbcacd186 fix: allow multiple provider keys per base url
Use a composite unique constraint and query filters so providers
can share base URLs with distinct API keys.
2026-01-25 22:18:39 +08:00
Shroominic
6d5b811c20 feat(ui): add preset selector to model override modal
Add the same preset selector that exists in the custom model creation
modal to the model override modal. This allows users to apply pricing
and settings from OpenRouter presets when creating overrides.

- Show preset selector for override mode (not just create mode)
- Preserve original model ID when applying preset in override mode
- Add contextual help text for override vs create mode

Closes #324
2026-01-25 22:04:51 +08:00
9qeklajc
42258ae39c fmt 2026-01-25 14:38:52 +01:00
9qeklajc
04d6903369 lint & fmt 2026-01-25 14:31:55 +01:00
9qeklajc
b0b2ceb1a0 create child keys within the dashboard 2026-01-25 12:24:02 +01:00
shroominic
bf91f401af Merge pull request #322 from Routstr/tiny-docs-fixes
tiny docs fixes
2026-01-24 21:17:57 +08:00
shroominic
9dfa58d69f Merge pull request #319 from Routstr/opencode-integ
Opencode integ
2026-01-24 14:40:56 +08:00
9qeklajc
22ec9c3132 fmt 2026-01-23 23:30:42 +01:00
9qeklajc
b72c578954 Merge branch 'v0.3.0' into opencode-integ 2026-01-23 21:44:16 +01:00
shroominic
7e299180fe Merge pull request #296 from Routstr/provider-fallback
Multi-Provider Fallback on UpstreamError
2026-01-23 09:50:04 +08:00
Shroominic
f290534df4 bump v0.3.0 2026-01-23 09:49:49 +08:00
Shroominic
8d3c064b29 ruff fix 2026-01-23 09:47:48 +08:00
shroominic
4ac96ade5f Merge branch 'v0.3.0' into provider-fallback 2026-01-23 09:45:14 +08:00
shroominic
180a469399 Merge pull request #304 from Routstr/introduce-child-key
Introduce child key
2026-01-23 09:35:53 +08:00
shroominic
87fbb48ca8 routstr v0.2.2 - fixfix
v0.2.2 - release summary
--------------------------
#292 - Fix not enough inputs to melt
#295 - Update UI dependencies
#291 - Fix reserved balance
#289 - Better filtering options (#282)
#284 - Do not charge for empty content (#274)
#298 - Fix Provider Balance display in dashboard
#299 - fix refunds not accounting reserved balance
#300 - reset reserved balance on startup (optional)
#303 - ignore disabled provider
#301 - optimize price fetching
2026-01-22 13:03:36 +01:00
9qeklajc
db021866d8 fmt u 2026-01-10 21:42:21 +01:00
9qeklajc
d4339287be chore: add type annotations to example and test files 2026-01-10 21:35:16 +01:00
9qeklajc
88bcc0edcb fmt 2026-01-10 21:34:48 +01:00
9qeklajc
917a4d32b1 ui: visualize parent-child relationship in balances page 2026-01-10 20:10:59 +01:00
9qeklajc
daf17f51ab fix: move child-key route before catch-all and fix indentation 2026-01-10 20:10:31 +01:00
9qeklajc
fc042c768c support child keys mapped to parent balance 2026-01-10 19:53:27 +01:00
Shroominic
cb36189db3 fmt 2026-01-09 17:04:09 +08:00
Shroominic
d2487f42b0 fix tests 2026-01-09 17:02:35 +08:00
Shroominic
5255fce7b2 todo comment 2026-01-09 16:55:24 +08:00
Shroominic
ee668ee93b handle specific errors eg deactivate ppq on insufficient balance 2026-01-09 16:49:03 +08:00
Shroominic
cf8b990fc7 fix specify error codes retry 2026-01-09 16:10:14 +08:00
Shroominic
78dd74845b fix merge 2026-01-07 17:44:28 +01:00
Shroominic
e7f4c98475 ranked provider fallback on upstream errors 2026-01-07 17:39:05 +01:00
213 changed files with 24352 additions and 16161 deletions

View File

@@ -67,7 +67,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
node-version: "20"
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml

5
.gitignore vendored
View File

@@ -38,3 +38,8 @@ proof_backups
*.todo
ui_out
output/
.pnpm-store/
# env files
.env*

50
Dockerfile.full Normal file
View File

@@ -0,0 +1,50 @@
# Multi-stage Dockerfile for Routstr (includes UI build)
# Stage 1: Build the UI
FROM node:23-alpine AS ui-builder
WORKDIR /app/ui
# Install pnpm
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
# Copy UI source
COPY ui/package.json ui/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY ui/ ./
ENV NEXT_TELEMETRY_DISABLED=1
# Next.js build produces a static export in 'out' directory
RUN pnpm run build
# Stage 2: Build the Routstr Node
FROM ghcr.io/astral-sh/uv:python3.11-alpine AS runner
# Install system dependencies
RUN apk add --no-cache \
pkgconf \
build-base \
automake \
autoconf \
libtool \
m4 \
perl \
git
WORKDIR /app
# Copy the rest of the application (required for uv sync to find the package)
COPY . .
# Install dependencies including the specific secp256k1 branch
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
RUN uv sync --no-dev
# Copy the built UI from the ui-builder stage
COPY --from=ui-builder /app/ui/out ./ui_out
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
# Run the application
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]

View File

@@ -6,7 +6,7 @@ services:
context: ./ui
dockerfile: Dockerfile.build
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
volumes:
- ./ui_out:/output

View File

@@ -360,6 +360,42 @@ POST /v1/wallet/create
}
```
### Get Key Information
Get current balance, consumption data, and child keys for an API key.
```http
GET /v1/balance/info
Authorization: Bearer sk-...
```
**Response:**
```json
{
"api_key": "sk-abc...",
"balance": 8500000,
"reserved": 0,
"is_child": false,
"parent_key": null,
"total_requests": 42,
"total_spent": 1500000,
"balance_limit": null,
"balance_limit_reset": null,
"validity_date": null,
"child_keys": [
{
"api_key": "sk-child1...",
"total_requests": 10,
"total_spent": 500000,
"balance_limit": 1000000,
"balance_limit_reset": "daily",
"validity_date": 1738000000
}
]
}
```
### Check Balance
Get current wallet balance.
@@ -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
## Admin Settings

View File

@@ -6,6 +6,32 @@ For automated deployments, you can optionally pre-configure settings via environ
---
## Initial Setup (.env file)
Before running your node, you should create a `.env` file in the project root. This file is used to bootstrap the initial configuration and store sensitive secrets.
### Example .env
```bash
ADMIN_PASSWORD=your-secure-password
# Node Identity
NAME="My AI Node"
DESCRIPTION="Fast access to models"
# Lightning Payouts
RECEIVE_LN_ADDRESS=yourname@wallet.com
```
### Setting the UI Password
There are two ways to set or change your Admin Dashboard password:
1. **Via Environment Variable**: Set `ADMIN_PASSWORD` in your `.env` file before starting the container. This will be the password used for the first login.
2. **Via Dashboard**: Once logged in, go to **Settings****Security** to update your password. Dashboard settings override the `.env` file once saved.
---
## Admin Dashboard (Primary)
Access the dashboard at `/admin/` on your node.
@@ -14,29 +40,29 @@ Access the dashboard at `/admin/` on your node.
Connect to your AI provider(s):
| Setting | Description |
|---------|-------------|
| Setting | Description |
| ---------------- | ------------------------------------------------ |
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
| **API Key** | Your provider's API key |
| **API Key** | Your provider's API key |
### Node Identity
How your node appears to clients:
| Setting | Description |
|---------|-------------|
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
| **Description** | Brief description of your service |
| Setting | Description |
| --------------- | -------------------------------------- |
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
| **Description** | Brief description of your service |
### Pricing
Control your profit margins:
| Setting | Description | Default |
|---------|-------------|---------|
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
| Setting | Description | Default |
| ----------------- | ------------------------------------------ | ------------ |
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
See [Pricing](pricing.md) for detailed strategies.
@@ -44,33 +70,33 @@ See [Pricing](pricing.md) for detailed strategies.
Which mints to accept payments from:
| Setting | Description |
|---------|-------------|
| Setting | Description |
| --------- | ------------------------------- |
| **Mints** | List of trusted Cashu mint URLs |
### Lightning Withdrawals
Automatic profit withdrawal:
| Setting | Description |
|---------|-------------|
| Setting | Description |
| --------------------- | ------------------------------- |
| **Lightning Address** | Your LN address for withdrawals |
### Security
| Setting | Description |
|---------|-------------|
| Setting | Description |
| ------------------ | ----------------------------- |
| **Admin Password** | Password for dashboard access |
### Nostr Discovery
Announce your node on the network:
| Setting | Description |
|---------|-------------|
| **Npub** | Your Nostr public key |
| **Nsec** | Your Nostr private key (for signing) |
| **Relays** | Relays to publish announcements |
| Setting | Description |
| ---------- | ------------------------------------ |
| **Npub** | Your Nostr public key |
| **Nsec** | Your Nostr private key (for signing) |
| **Relays** | Relays to publish announcements |
See [Discovery](discovery.md) for details.
@@ -86,21 +112,21 @@ Use environment variables for:
### All Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
| `UPSTREAM_API_KEY` | Upstream API key | — |
| `ADMIN_PASSWORD` | Dashboard password | (none) |
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
| `NAME` | Node display name | `ARoutstrNode` |
| `DESCRIPTION` | Node description | `A Routstr Node` |
| `NPUB` | Nostr public key (bech32) | — |
| `NSEC` | Nostr private key | — |
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
| Variable | Description | Default |
| -------------------- | --------------------------------- | ------------------------------------ |
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
| `UPSTREAM_API_KEY` | Upstream API key | — |
| `ADMIN_PASSWORD` | Dashboard password | (none) |
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
| `NAME` | Node display name | `ARoutstrNode` |
| `DESCRIPTION` | Node description | `A Routstr Node` |
| `NPUB` | Nostr public key (bech32) | — |
| `NSEC` | Nostr private key | — |
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
### Priority

View File

@@ -6,30 +6,25 @@ Production deployment guide for Routstr Provider nodes.
For production, use Docker Compose with persistent storage and optional Tor support.
### Basic Setup
### Unified Setup (All-in-one)
To build and run the node with the UI integrated in a single container using the multi-stage build:
Create a `compose.yml`:
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
container_name: routstr
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
```bash
docker build -f Dockerfile.full -t routstr-full .
docker run -d -p 8000:8000 --env-file .env routstr-full
```
Start the node:
### Advanced Setup (Separated UI & Node)
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
```bash
docker compose up -d
```
Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/).
This will:
1. **Build the UI**: Compiles the frontend and copies it to a shared volume.
2. **Start Routstr**: Runs the Python node, mounting the built UI.
3. **Start Tor**: Provides anonymous access via a `.onion` address.
---
@@ -189,8 +184,20 @@ docker compose up -d
## Building from Source
### Unified Image (UI + Node)
The easiest way to build everything from source into a single production-ready image:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
docker build -t routstr-local .
docker build -f Dockerfile.full -t routstr-full .
```
### Individual Components
If you prefer building them separately or using Docker Compose:
```bash
# Build using compose
docker compose build
# Or build the node only (requires manual UI build first)
docker build -t routstr-node .
```

View File

@@ -13,7 +13,7 @@ A **Routstr Provider Node** acts as a gateway that:
You bring the API keys, Routstr handles the billing, payments, and client management.
!!! tip "Future: Node-to-Node Routing"
In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials.
In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials.
---
@@ -24,16 +24,53 @@ You bring the API keys, Routstr handles the billing, payments, and client manage
---
## 1. Start the Node
## 1. Prepare Configuration
Create a `.env` file in the root of the project to store your secrets:
```bash
# Initial Admin Password
ADMIN_PASSWORD=mysecretpassword
# Node Identity
NAME="My AI Node"
DESCRIPTION="Fast access to models"
# Lightning Payouts
RECEIVE_LN_ADDRESS=yourname@wallet.com
```
## 2. Start the Node
You can run the pre-built image directly:
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
--env-file .env \
-v routstr-data:/app/data \
ghcr.io/routstr/proxy:latest
```
*Note: The pre-built image does not contain the UI. For the all-in-one experience with the Admin Dashboard, use the Build from Source instructions below.*
### Build from Source (Recommended)
If you want to build the node and UI yourself from source, use the unified Dockerfile:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
# Edit your .env with ADMIN_PASSWORD and API keys
cp .env.example .env
nano .env
docker build -f Dockerfile.full -t routstr-local .
docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local
```
Verify it's running:
```bash
@@ -42,12 +79,12 @@ curl http://localhost:8000/v1/info
---
## 2. Configure via Dashboard
## 3. Configure via Dashboard
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
!!! note "Default Access"
The dashboard has no password by default. Set one immediately in Settings for production use.
!!! note "Login"
Use the `ADMIN_PASSWORD` you defined in your `.env` file to log in. If you didn't set one, the dashboard will prompt you to set one on first visit.
### Connect Your AI Providers

View File

@@ -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.")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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,
)

View File

@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.2.2"
version = "0.4.0"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -84,93 +84,26 @@ def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
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(
upstreams: list["BaseUpstreamProvider"],
overrides_by_id: dict[str, tuple],
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.
This is the main entry point for the algorithm. It processes all upstream providers
and creates three mappings based on cost optimization:
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)
The algorithm:
- Processes non-OpenRouter providers first (they're typically 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:
upstreams: List of all upstream provider instances
@@ -183,9 +116,15 @@ def create_model_mappings(
from .payment.models import _row_to_model
from .upstream.helpers import resolve_model_alias
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, "BaseUpstreamProvider"] = {}
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
seen_model_provider: set[tuple[str, str]] = set()
providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {}
for upstream in upstreams:
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
providers_by_db_id[db_id] = upstream
# Separate OpenRouter from other providers
openrouter: "BaseUpstreamProvider" | None = None
@@ -202,30 +141,31 @@ def create_model_mappings(
"""Get base model ID by removing provider prefix."""
return model_id.split("/", 1)[1] if "/" in model_id else model_id
def _maybe_set_alias(
def get_provider_identity(upstream: "BaseUpstreamProvider") -> str:
"""Get a stable provider identity used for deduplication."""
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
return f"db:{db_id}"
provider_type = str(getattr(upstream, "provider_type", "") or "").lower()
base_url = str(getattr(upstream, "base_url", "") or "").lower()
return f"{provider_type}|{base_url}"
def _add_candidate(
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
"""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()
existing_model = model_instances.get(alias_lower)
if not existing_model:
# No existing mapping, set it
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
if alias_lower not in candidates:
candidates[alias_lower] = []
candidates[alias_lower].append((model, provider))
def process_provider_models(
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
) -> None:
"""Process all models from a given provider."""
upstream_prefix = getattr(upstream, "upstream_name", None)
provider_key = get_provider_identity(upstream)
for model in upstream.get_cached_models():
if not model.enabled or model.id in disabled_model_ids:
@@ -266,21 +206,135 @@ def create_model_mappings(
# Try to set each alias
for alias in aliases:
_maybe_set_alias(alias, model_to_use, upstream)
_add_candidate(alias, model_to_use, upstream)
seen_model_provider.add((model_to_use.id.lower(), provider_key))
# Process non-OpenRouter providers first (they're typically cheaper)
# Process non-OpenRouter providers first
for upstream in other_upstreams:
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:
process_provider_models(openrouter, is_openrouter=True)
# Log provider distribution
# Include enabled DB overrides even when provider discovery misses models.
# This is important for deployment-based providers like Azure.
for model_id, override_data in overrides_by_id.items():
if model_id in disabled_model_ids:
continue
override_row, provider_fee = override_data
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
if not isinstance(upstream_provider_id, int):
continue
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
if upstream_for_override is None:
continue
provider_key = get_provider_identity(upstream_for_override)
dedupe_key = (model_id.lower(), provider_key)
if dedupe_key in seen_model_provider:
continue
try:
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
except Exception as exc:
logger.warning(
"Skipping invalid model override while building model mappings",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
if not model_to_use.enabled:
continue
base_id = get_base_model_id(model_to_use.id)
is_openrouter = (
getattr(upstream_for_override, "base_url", "")
== "https://openrouter.ai/api/v1"
)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream_for_override.provider_type,
}
)
unique_models[base_id] = unique_model
try:
aliases = resolve_model_alias(
model_to_use.id,
model_to_use.canonical_slug,
alias_ids=model_to_use.alias_ids,
)
except Exception as exc:
logger.warning(
"Skipping model aliases for invalid override model",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
upstream_prefix = getattr(upstream_for_override, "upstream_name", None)
if upstream_prefix and "/" not in model_to_use.id:
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
if prefixed_id not in aliases:
aliases.append(prefixed_id)
for alias in aliases:
_add_candidate(alias, model_to_use, upstream_for_override)
seen_model_provider.add(dedupe_key)
# Sort candidates and build final maps
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
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] = {}
for provider in provider_map.values():
provider_name = getattr(provider, "upstream_name", "unknown")
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
for providers in provider_map.values():
if providers:
provider = providers[0]
provider_name = getattr(provider, "upstream_name", "unknown")
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
logger.debug(
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",

View File

@@ -1,10 +1,14 @@
import asyncio
import hashlib
import math
import random
import time
from datetime import datetime
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, update
from sqlmodel import col, select, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
@@ -24,16 +28,60 @@ logger = get_logger(__name__)
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
"""Checks if a key's balance limit should be reset based on its policy."""
if key.balance_limit is not None and key.balance_limit_reset:
now = int(time.time())
reset_date = key.balance_limit_reset_date or 0
should_reset = False
if key.balance_limit_reset == "daily":
if (
datetime.fromtimestamp(now).date()
> datetime.fromtimestamp(reset_date).date()
):
should_reset = True
elif key.balance_limit_reset == "weekly":
if (
datetime.fromtimestamp(now).isocalendar()[:2]
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
):
should_reset = True
elif key.balance_limit_reset == "monthly":
dt_now = datetime.fromtimestamp(now)
dt_reset = datetime.fromtimestamp(reset_date)
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
should_reset = True
if should_reset:
logger.info(
"Resetting balance limit for key",
extra={
"key_hash": key.hashed_key[:8] + "...",
"policy": key.balance_limit_reset,
"old_spent": key.total_spent,
},
)
key.total_spent = 0
key.balance_limit_reset_date = now
session.add(key)
await session.flush()
return True
return False
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
min_cost: int = 0,
) -> ApiKey:
"""
Validates the provided API key using SQLModel.
If it's a cashu key, it redeems it and stores its hash and balance.
Otherwise checks if the hash of the key exists.
Includes a balance check against min_cost for limited keys.
"""
logger.debug(
"Starting bearer key validation",
@@ -43,6 +91,7 @@ async def validate_bearer_key(
else bearer_key,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
"min_cost": min_cost,
},
)
@@ -97,6 +146,50 @@ async def validate_bearer_key(
},
)
# Check and reset limit if needed
await check_and_reset_limit(existing_key, session)
# Early check: Billing balance check (Parent balance)
billing_key = await get_billing_key(existing_key, session)
if min_cost > 0 and billing_key.total_balance < min_cost:
logger.warning(
"Insufficient billing balance during validation",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"balance": billing_key.total_balance,
"required": min_cost,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {min_cost} mSats required for this model. {billing_key.total_balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
# Early check: Spending limit check (Child key limit)
if (
min_cost > 0
and existing_key.balance_limit is not None
and existing_key.total_spent + existing_key.reserved_balance + min_cost
> existing_key.balance_limit
):
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Balance limit exceeded: {existing_key.balance_limit} mSats limit. {existing_key.total_spent} already spent ({existing_key.reserved_balance} reserved), {min_cost} minimum required for this model.",
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
)
return existing_key
else:
logger.warning(
@@ -152,6 +245,19 @@ async def validate_bearer_key(
},
)
# Early check: Billing balance check
if min_cost > 0 and existing_key.total_balance < min_cost:
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {min_cost} mSats required for this model. {existing_key.total_balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
return existing_key
logger.info(
@@ -242,6 +348,8 @@ async def validate_bearer_key(
)
return new_key
except HTTPException:
raise
except Exception as e:
logger.error(
"Cashu token redemption failed",
@@ -286,30 +394,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(
key: ApiKey, cost_per_request: int, session: AsyncSession
) -> int:
"""Process payment for a request."""
# Ensure cost_per_request is at least the minimum allowed request cost
cost_per_request = max(cost_per_request, settings.min_request_msat)
billing_key = await get_billing_key(key, session)
logger.info(
"Processing payment for request",
extra={
"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,
"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(
"Insufficient balance for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"balance": key.balance,
"reserved_balance": key.reserved_balance,
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"required": cost_per_request,
"shortfall": cost_per_request - key.total_balance,
"shortfall": cost_per_request - billing_key.total_balance,
},
)
@@ -317,26 +452,78 @@ async def pay_for_request(
status_code=402,
detail={
"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",
"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(
"Charging base cost for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"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
stmt = (
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)
.values(
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
@@ -344,6 +531,19 @@ async def pay_for_request(
)
)
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()
if result.rowcount == 0:
@@ -351,8 +551,9 @@ async def pay_for_request(
"Concurrent request depleted balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"required_cost": cost_per_request,
"current_balance": key.balance,
"current_balance": billing_key.balance,
},
)
@@ -361,23 +562,26 @@ async def pay_for_request(
status_code=402,
detail={
"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",
"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(
"Payment processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost_per_request,
"new_balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"new_balance": billing_key.balance,
"total_spent": billing_key.total_spent,
"total_requests": billing_key.total_requests,
},
)
@@ -386,10 +590,15 @@ async def pay_for_request(
async def revert_pay_for_request(
key: ApiKey, session: AsyncSession, cost_per_request: int
) -> None:
) -> bool:
"""Revert a previously reserved payment. Returns True if revert succeeded,
False if the reservation was already released (prevents negative reserved_balance)."""
billing_key = await get_billing_key(key, session)
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
total_requests=col(ApiKey.total_requests) - 1,
@@ -397,27 +606,36 @@ async def revert_pay_for_request(
)
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)
.where(col(ApiKey.reserved_balance) >= cost_per_request)
.values(
total_requests=col(ApiKey.total_requests) - 1,
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to revert payment - insufficient reserved balance",
logger.warning(
"Revert skipped - reservation already released (no-op to prevent negative reserved_balance)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost_to_revert": cost_per_request,
"current_reserved_balance": key.reserved_balance,
"current_reserved_balance": billing_key.reserved_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
await session.refresh(key)
return False
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
return True
async def adjust_payment_for_tokens(
@@ -428,15 +646,17 @@ async def adjust_payment_for_tokens(
This is called after the initial payment and the upstream request is complete.
Returns cost data to be included in the response.
"""
billing_key = await get_billing_key(key, session)
model = response_data.get("model", "unknown")
logger.debug(
"Starting payment adjustment for tokens",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"deducted_max_cost": deducted_max_cost,
"current_balance": key.balance,
"current_balance": billing_key.balance,
"has_usage": "usage" in response_data,
},
)
@@ -446,22 +666,54 @@ async def adjust_payment_for_tokens(
try:
release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
)
)
await session.exec(release_stmt) # type: ignore[call-overload]
result = await session.exec(release_stmt) # type: ignore[call-overload]
# Also release on child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost
)
)
await session.exec(child_release_stmt) # type: ignore[call-overload]
await session.commit()
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
if result.rowcount == 0: # type: ignore[union-attr]
logger.warning(
"Release reservation skipped - already released (no-op to prevent negative reserved_balance)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
else:
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
except Exception as e:
logger.error(
"Failed to release reservation in fallback",
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):
@@ -470,6 +722,7 @@ async def adjust_payment_for_tokens(
"Using max cost data (no token adjustment)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"max_cost": cost.total_msats,
},
@@ -477,7 +730,7 @@ async def adjust_payment_for_tokens(
# Finalize by releasing reservation and charging max cost
finalize_stmt = (
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,
balance=col(ApiKey.balance) - cost.total_msats,
@@ -485,27 +738,47 @@ async def adjust_payment_for_tokens(
)
)
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()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - retrying reservation release",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"current_reserved_balance": billing_key.reserved_balance,
"total_cost": cost.total_msats,
"model": model,
},
)
await release_reservation_only()
else:
await session.refresh(key)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Max cost payment finalized",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"new_balance": key.balance,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"new_balance": billing_key.balance,
"model": model,
},
)
@@ -521,23 +794,30 @@ async def adjust_payment_for_tokens(
"Calculated token-based cost",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"token_cost": cost.total_msats,
"deducted_max_cost": deducted_max_cost,
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
},
)
if cost_difference == 0:
logger.debug(
"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 = (
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,
@@ -546,8 +826,24 @@ async def adjust_payment_for_tokens(
)
)
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.refresh(key)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
return cost.dict()
# this should never happen why do we handle this???
@@ -557,16 +853,17 @@ async def adjust_payment_for_tokens(
"Additional charge required for token usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"additional_charge": cost_difference,
"current_balance": key.balance,
"sufficient_balance": key.balance >= cost_difference,
"current_balance": billing_key.balance,
"sufficient_balance": billing_key.balance >= cost_difference,
"model": model,
},
)
finalize_stmt = (
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,
@@ -575,18 +872,35 @@ async def adjust_payment_for_tokens(
)
)
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()
if result.rowcount:
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(
"Finalized payment with additional charge",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": total_cost_msats,
"new_balance": key.balance,
"new_balance": billing_key.balance,
"model": model,
},
)
@@ -595,6 +909,7 @@ async def adjust_payment_for_tokens(
"Failed to finalize additional charge - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"attempted_charge": total_cost_msats,
"model": model,
},
@@ -607,15 +922,16 @@ async def adjust_payment_for_tokens(
"Refunding excess payment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"refund_amount": refund,
"current_balance": key.balance,
"current_balance": billing_key.balance,
"model": model,
},
)
refund_stmt = (
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,
@@ -624,6 +940,20 @@ async def adjust_payment_for_tokens(
)
)
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()
if result.rowcount == 0:
@@ -631,8 +961,9 @@ async def adjust_payment_for_tokens(
"Failed to finalize payment - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"current_reserved_balance": billing_key.reserved_balance,
"total_cost": total_cost_msats,
"model": model,
},
@@ -640,14 +971,17 @@ async def adjust_payment_for_tokens(
await release_reservation_only()
else:
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(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": key.balance,
"new_balance": billing_key.balance,
"final_cost": cost.total_msats,
"model": model,
},
@@ -677,15 +1011,67 @@ async def adjust_payment_for_tokens(
}
},
)
# Fallback: should not reach here, but release reservation just in case
logger.error(
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
await release_reservation_only()
return {
"base_msats": deducted_max_cost,
"input_msats": 0,
"output_msats": 0,
"total_msats": deducted_max_cost,
}
# All calculate_cost variants are handled above.
raise AssertionError("Unreachable: unhandled calculate_cost result")
async def periodic_key_reset() -> None:
"""Background task to reset key limits based on their policy."""
from .core.db import create_session
while True:
try:
interval = 3600 # Run every hour
jitter = 300
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
try:
async with create_session() as session:
# Find all keys that have a reset policy
stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore
keys = (await session.exec(stmt)).all()
now = int(time.time())
updated_count = 0
for key in keys:
reset_date = key.balance_limit_reset_date or 0
should_reset = False
if key.balance_limit_reset == "daily":
if (
datetime.fromtimestamp(now).date()
> datetime.fromtimestamp(reset_date).date()
):
should_reset = True
elif key.balance_limit_reset == "weekly":
if (
datetime.fromtimestamp(now).isocalendar()[:2]
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
):
should_reset = True
elif key.balance_limit_reset == "monthly":
dt_now = datetime.fromtimestamp(now)
dt_reset = datetime.fromtimestamp(reset_date)
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
should_reset = True
if should_reset:
key.total_spent = 0
key.balance_limit_reset_date = now
session.add(key)
updated_count += 1
if updated_count > 0:
await session.commit()
logger.info(
"Periodic key reset complete",
extra={"keys_reset": updated_count},
)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in periodic_key_reset: {e}")

View File

@@ -1,12 +1,14 @@
import asyncio
import hashlib
import time
from time import monotonic
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlmodel import select
from .auth import validate_bearer_key
from .auth import get_billing_key, validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .core.logging import get_logger
from .core.settings import settings
@@ -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
@router.get("/", include_in_schema=False)
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
"reserved": key.reserved_balance,
}
async def account_info(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
return await get_balance_info(key, session)
# 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")
async def create_balance(
initial_balance_token: str, session: AsyncSession = Depends(get_session)
initial_balance_token: str,
balance_limit: int | None = None,
balance_limit_reset: str | None = None,
validity_date: int | None = None,
session: AsyncSession = Depends(get_session),
) -> dict:
key = await validate_bearer_key(initial_balance_token, session)
if balance_limit is not None or balance_limit_reset or validity_date:
key.balance_limit = balance_limit
key.balance_limit_reset = balance_limit_reset
key.validity_date = validity_date
if balance_limit_reset:
key.balance_limit_reset_date = int(time.time())
session.add(key)
await session.commit()
await session.refresh(key)
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
@@ -66,12 +118,11 @@ async def create_balance(
@router.get("/info")
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
"reserved": key.reserved_balance,
}
async def wallet_info(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
return await get_balance_info(key, session)
class TopupRequest(BaseModel):
@@ -85,6 +136,8 @@ async def topup_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
billing_key = await get_billing_key(key, session)
if topup_request is not None:
cashu_token = topup_request.cashu_token
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:
raise HTTPException(status_code=400, detail="Invalid token format")
try:
amount_msats = await credit_balance(cashu_token, key, session)
amount_msats = await credit_balance(cashu_token, billing_key, session)
except ValueError as e:
error_msg = str(e)
if "already spent" in error_msg.lower():
@@ -150,11 +203,18 @@ async def refund_wallet_endpoint(
bearer_value: str = authorization[7:]
if cached := await _refund_cache_get(bearer_value):
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
if key.total_balance <= 0:
if cached := await _refund_cache_get(bearer_value):
return cached
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot refund child key. Please refund the parent key instead.",
)
remaining_balance_msats: int = key.total_balance
if key.refund_currency == "sat":
@@ -209,7 +269,9 @@ async def refund_wallet_endpoint(
await _refund_cache_set(bearer_value, result)
await session.delete(key)
key.balance = 0
key.reserved_balance = 0
session.add(key)
await session.commit()
return result
@@ -228,6 +290,117 @@ async def donate(token: str, ref: str | None = None) -> str:
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(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,13 @@
import os
import pathlib
import sqlite3
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from sqlalchemy import UniqueConstraint
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -47,6 +50,25 @@ class ApiKey(SQLModel, table=True): # type: ignore
default=None,
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
def total_balance(self) -> int:
@@ -108,11 +130,16 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
__tablename__ = "upstream_providers"
__table_args__ = (
UniqueConstraint(
"base_url", "api_key", name="uq_upstream_providers_base_url_api_key"
),
)
id: int | None = Field(default=None, primary_key=True)
provider_type: str = Field(
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_version: str | None = Field(
default=None, description="API version for Azure OpenAI"
@@ -156,11 +183,53 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]:
yield session
def fix_cashu_migrations() -> None:
"""
Fixes Cashu wallet migrations that are not idempotent.
This specifically addresses the 'duplicate column name: public_keys' error
in the keysets table of Cashu's internal SQLite databases.
"""
project_root = pathlib.Path(__file__).resolve().parents[2]
wallet_dir = project_root / ".wallet"
if not wallet_dir.exists() or not wallet_dir.is_dir():
return
logger.info("Checking Cashu wallet databases for migration idempotency")
for db_file in wallet_dir.glob("*.sqlite3"):
try:
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
# Check if keysets table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'"
)
if not cursor.fetchone():
conn.close()
continue
# Check if public_keys column exists
cursor.execute("PRAGMA table_info(keysets)")
columns = [info[1] for info in cursor.fetchall()]
if "public_keys" not in columns:
logger.info(f"Adding missing public_keys column to {db_file.name}")
cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT")
conn.commit()
conn.close()
except Exception as e:
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
import pathlib
try:
# Run Cashu migration fix first
fix_cashu_migrations()
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"

View File

@@ -6,6 +6,15 @@ from .logging import get_logger
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:
"""Handle HTTP exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")

File diff suppressed because it is too large Load Diff

View File

@@ -3,36 +3,38 @@ Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
The following log messages are parsed by the usage tracking system
(routstr/core/usage_analytics_store.py and routstr/core/log_manager.py).
DO NOT modify or remove these messages without updating the usage tracking logic:
1. "Received proxy request" (INFO) - routstr/proxy.py
- Used to count total incoming requests
- Includes model information in context
2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py
"Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
2. "Calculated token-based cost" (INFO) - routstr/auth.py
- Used to track successful completions and revenue
- The 'cost_data.total_msats' field is extracted for revenue calculation
- Must include 'cost_data' in extra dict
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
3. "Payment processed successfully" (INFO) - routstr/auth.py
3. "Max cost payment finalized" (INFO) - routstr/auth.py
- Used as the successful completion fallback when token usage is unavailable
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
4. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
- Tracks payment-related metrics
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
5. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
- Used to track failed requests and refunds
- The 'max_cost_for_model' field is extracted for refund calculation
- Must include 'max_cost_for_model' in extra dict
5. Any ERROR level logs with "upstream" in the message
6. Any ERROR level logs with "upstream" in the message
- Used to count upstream provider errors
- Helps identify service reliability issues
If you need to modify these messages, ensure you also update the parsing logic in:
- routstr/core/admin.py:_aggregate_metrics_by_time()
- routstr/core/admin.py:_get_summary_stats()
- routstr/core/admin.py:get_revenue_by_model()
- routstr/core/usage_analytics_store.py
- routstr/core/log_manager.py
"""
import logging.config

View File

@@ -10,13 +10,11 @@ from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException
from ..auth import periodic_key_reset
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import (
models_router,
update_sats_pricing,
)
from ..nostr import announce_provider, providers_cache_refresher
from ..nostr.discovery import providers_router
from ..payment.models import models_router, update_sats_pricing
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
from ..wallet import periodic_payout
@@ -33,9 +31,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.4.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.2.2"
__version__ = "0.4.0"
@asynccontextmanager
@@ -49,6 +47,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
providers_task = None
models_refresh_task = None
model_maps_refresh_task = None
key_reset_task = None
try:
# Run database migrations on startup
@@ -104,6 +103,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
key_reset_task = asyncio.create_task(periodic_key_reset())
yield
@@ -133,6 +133,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
models_refresh_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
if key_reset_task is not None:
key_reset_task.cancel()
try:
tasks_to_wait = []
@@ -150,6 +152,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(models_refresh_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if key_reset_task is not None:
tasks_to_wait.append(key_reset_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
@@ -191,6 +195,7 @@ async def info() -> dict:
"mints": global_settings.cashu_mints,
"http_url": global_settings.http_url,
"onion_url": global_settings.onion_url,
"child_key_cost_msats": global_settings.child_key_cost,
}

View File

@@ -52,6 +52,7 @@ class Settings(BaseSettings):
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
reset_reserved_balance_on_startup: bool = Field(
@@ -142,7 +143,7 @@ def resolve_bootstrap() -> Settings:
pass
if not base.onion_url:
try:
from ..nip91 import discover_onion_url_from_tor # type: ignore
from ..nostr.listing import discover_onion_url_from_tor # type: ignore
discovered = discover_onion_url_from_tor()
if discovered:

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,9 @@ class InvoiceCreateRequest(BaseModel):
api_key: str | None = Field(
default=None, description="Required for topup operations"
)
balance_limit: int | None = Field(default=None)
balance_limit_reset: str | None = Field(default=None)
validity_date: int | None = Field(default=None)
class InvoiceCreateResponse(BaseModel):
@@ -94,6 +97,9 @@ async def create_invoice(
status="pending",
api_key_hash=request.api_key[3:] if request.api_key else None,
purpose=request.purpose,
balance_limit=request.balance_limit,
balance_limit_reset=request.balance_limit_reset,
validity_date=request.validity_date,
expires_at=expires_at,
)

View File

@@ -0,0 +1,4 @@
from .discovery import providers_cache_refresher
from .listing import announce_provider
__all__ = ["providers_cache_refresher", "announce_provider"]

View File

@@ -8,8 +8,8 @@ import httpx
import websockets
from fastapi import APIRouter, HTTPException
from .core.logging import get_logger
from .core.settings import settings
from ..core.logging import get_logger
from ..core.settings import settings
logger = get_logger(__name__)
@@ -72,8 +72,6 @@ async def query_nostr_relay_for_providers(
elif data[0] == "NOTICE":
try:
msg = str(data[1])
if len(msg) > 200:
msg = msg[:200] + "..."
logger.debug(f"Relay notice: {msg}")
except Exception:
logger.debug("Relay notice received")

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
NIP-91: Routstr Provider Discoverability Implementation
Listing: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
@@ -18,15 +18,15 @@ from nostr.key import PrivateKey
from nostr.message_type import ClientMessageType
from nostr.relay_manager import RelayManager
from .core import get_logger
from .core.settings import settings
from ..core import get_logger
from ..core.settings import settings
logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
from .core.main import __version__ as imported_version
from ..core.main import __version__ as imported_version
return imported_version
except Exception:
@@ -71,7 +71,7 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
return None
def create_nip91_event(
def create_listing_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
@@ -80,7 +80,7 @@ def create_nip91_event(
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a NIP-91 compliant provider announcement event (kind:38421).
Create a listing provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
@@ -164,14 +164,14 @@ def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
return True
async def query_nip91_events(
async def query_listing_events(
relay_url: str,
pubkey: str,
provider_id: str | None = None,
timeout: int = 30,
) -> tuple[list[dict[str, Any]], bool]:
"""
Query a Nostr relay for NIP-91 provider announcements (kind:38421) via nostr library.
Query a Nostr relay for listing provider announcements (kind:38421) via nostr library.
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
succeeded without transport-level errors.
@@ -188,7 +188,7 @@ async def query_nip91_events(
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
filters = Filters([flt])
sub_id = f"nip91_{int(time.time())}"
sub_id = f"routstr_listing_{int(time.time())}"
rm.add_subscription(sub_id, filters)
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
req.extend(filters.to_json_array())
@@ -294,7 +294,7 @@ async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) ->
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
try:
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
events, _ok = await query_listing_events(relay_url, public_key_hex, None)
return events
except Exception:
return []
@@ -330,7 +330,7 @@ async def publish_to_relay(
timeout: int = 30,
) -> bool:
"""
Publish a NIP-91 event to a nostr relay via nostr library.
Publish a listing event to a nostr relay via nostr library.
"""
def _sync_publish() -> bool:
@@ -341,7 +341,7 @@ async def publish_to_relay(
time.sleep(1.0)
# Publish the event as-is via publish_message to preserve signature
rm.publish_message(json.dumps(["EVENT", event]))
logger.debug(f"Sent NIP-91 event {event.get('id', '')} to {relay_url}")
logger.debug(f"Sent listing event {event.get('id', '')} to {relay_url}")
time.sleep(1.0)
return True
except Exception as e:
@@ -364,13 +364,13 @@ async def announce_provider() -> None:
# Check for NSEC in environment (use NSEC only)
nsec = settings.nsec
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
logger.info("Nostr private key not found (NSEC), skipping listing announcement")
return
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
logger.error("Failed to parse NSEC, skipping listing announcement")
return
private_key_hex, public_key_hex = keypair
@@ -409,7 +409,7 @@ async def announce_provider() -> None:
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping listing publish."
)
return
@@ -434,7 +434,7 @@ async def announce_provider() -> None:
# Create the candidate event that we would publish
version_str = get_app_version()
candidate_event = create_nip91_event(
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
@@ -474,7 +474,7 @@ async def announce_provider() -> None:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
events, ok = await query_listing_events(relay_url, public_key_hex, provider_id)
if ok:
_register_success(relay_url)
existing_events.extend(events)
@@ -489,7 +489,7 @@ async def announce_provider() -> None:
if not all_match:
logger.debug(
"No matching NIP-91 announcement found or differences detected; publishing update"
"No matching listing announcement found or differences detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
@@ -502,11 +502,11 @@ async def announce_provider() -> None:
else:
_register_failure(relay_url)
logger.info(
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
f"Published listing announcement to {success_count}/{len(relay_urls)} relays"
)
else:
logger.debug(
"Matching NIP-91 announcement already present; skipping publish on startup"
"Matching listing announcement already present; skipping publish on startup"
)
# Re-announce periodically (every 24 hours)
@@ -518,7 +518,7 @@ async def announce_provider() -> None:
# Build fresh candidate event for comparison
version_str = get_app_version()
candidate_event = create_nip91_event(
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
@@ -533,7 +533,7 @@ async def announce_provider() -> None:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(
events, ok = await query_listing_events(
relay_url, public_key_hex, provider_id
)
if ok:
@@ -549,7 +549,7 @@ async def announce_provider() -> None:
if all_match:
logger.debug(
"Matching NIP-91 announcement already present; skipping periodic re-announce"
"Matching listing announcement already present; skipping periodic re-announce"
)
continue
@@ -567,8 +567,8 @@ async def announce_provider() -> None:
_register_failure(relay_url)
except asyncio.CancelledError:
logger.info("NIP-91 announcement task cancelled")
logger.info("Listing announcement task cancelled")
break
except Exception as e:
logger.debug(f"Error in NIP-91 announcement loop: {type(e).__name__}")
logger.debug(f"Error in listing announcement loop: {type(e).__name__}")
# Continue running despite errors

View File

@@ -15,6 +15,9 @@ class CostData(BaseModel):
input_msats: int
output_msats: int
total_msats: int
total_usd: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
class MaxCostData(CostData):
@@ -61,10 +64,50 @@ async def calculate_cost( # todo: can be sync
input_msats=0,
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
)
usage_data = response_data["usage"]
def parse_token_count(value: object) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
output_tokens = parse_token_count(usage_data.get("completion_tokens", 0))
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(usage_data.get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(usage_data.get("output_tokens", 0))
)
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("output_tokens", 0))
)
usd_cost = 0.0
# Prioritize cost_details.upstream_inference_cost
@@ -101,6 +144,9 @@ async def calculate_cost( # todo: can be sync
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
total_msats=cost_in_msats,
total_usd=usd_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except Exception as e:
logger.warning(
@@ -181,35 +227,15 @@ async def calculate_cost( # todo: can be sync
input_msats=0,
output_msats=0,
total_msats=max_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
# added for response api
input_tokens = (
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
)
output_tokens = (
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
)
# added for response api
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
logger.info(
"Calculated token-based cost",
@@ -219,6 +245,7 @@ async def calculate_cost( # todo: can be sync
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
"total_cost_msats": token_based_cost,
"total_usd": total_usd,
"model": response_data.get("model", "unknown"),
},
)
@@ -228,4 +255,7 @@ async def calculate_cost( # todo: can be sync
input_msats=int(input_msats),
output_msats=int(output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
input_tokens=input_tokens,
output_tokens=output_tokens,
)

View File

@@ -29,15 +29,13 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
},
)
elif auth := headers.get("authorization", None):
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
logger.debug(
"Using Authorization header token",
"Skipping preflight token balance check for Authorization header",
extra={
"token_preview": cashu_token[:20] + "..."
if len(cashu_token) > 20
else cashu_token
"auth_preview": auth[:20] + "..." if len(auth) > 20 else auth,
},
)
return
else:
logger.error("No authentication token provided")
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -75,7 +73,7 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
if max_cost_for_model > amount_msat:
raise HTTPException(
status_code=413,
status_code=402,
detail={
"reason": "Insufficient balance",
"amount_required_msat": max_cost_for_model,
@@ -169,9 +167,32 @@ async def calculate_discounted_max_cost(
tol = settings.tolerance_percentage
tol_factor = max(0.0, 1 - float(tol) / 100.0)
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
if model_obj:
prompt_token_limit: int | None = None
if model_obj.top_provider and (
model_obj.top_provider.context_length
or model_obj.top_provider.max_completion_tokens
):
cl = model_obj.top_provider.context_length
mct = model_obj.top_provider.max_completion_tokens
if cl and mct:
prompt_token_limit = max(0, cl - mct)
elif cl:
prompt_token_limit = cl
elif mct:
prompt_token_limit = 0
elif model_obj.context_length:
prompt_token_limit = model_obj.context_length
if prompt_token_limit is not None:
max_prompt_allowed_sats = (
prompt_token_limit * model_pricing.prompt * tol_factor
)
adjusted = max_cost_for_model
if messages := body.get("messages"):

View File

@@ -25,82 +25,6 @@ class LNURLError(Exception):
"""LNURL related errors."""
def parse_lightning_invoice_amount(invoice: str, currency: str = "sat") -> int:
"""Parse Lightning invoice (BOLT-11) to extract amount in specified currency units.
Args:
invoice: BOLT-11 Lightning invoice string
currency: Target currency unit ("sat" or "msat")
Returns:
Amount in the specified currency unit
Raises:
LNURLError: If invoice format is invalid or amount cannot be parsed
"""
invoice = invoice.lower().strip()
if not invoice.startswith("ln"):
raise LNURLError("Invalid Lightning invoice format")
# Find the network part (bc, tb, etc.)
network_start = 2
while network_start < len(invoice) and invoice[network_start] not in "0123456789":
network_start += 1
if network_start >= len(invoice):
raise LNURLError("Invalid Lightning invoice format")
# Parse amount and multiplier
amount_str = ""
multiplier = ""
i = network_start
# Extract numeric part
while i < len(invoice) and invoice[i].isdigit():
amount_str += invoice[i]
i += 1
# Extract multiplier if present
if i < len(invoice) and invoice[i] in "munp":
multiplier = invoice[i]
i += 1
# Check if we have the required "1" separator
if i >= len(invoice) or invoice[i] != "1":
raise LNURLError("Invalid Lightning invoice format")
if not amount_str:
raise LNURLError("Lightning invoice amount not specified")
# Convert to base units
try:
amount = int(amount_str)
except ValueError:
raise LNURLError("Invalid Lightning invoice amount")
# Apply multiplier to get millisatoshis
if multiplier == "m": # milli = 10^-3
amount_msat = amount * 100_000_000 # amount is in BTC * 10^-3
elif multiplier == "u": # micro = 10^-6
amount_msat = amount * 100_000 # amount is in BTC * 10^-6
elif multiplier == "n": # nano = 10^-9
amount_msat = amount * 100 # amount is in BTC * 10^-9
elif multiplier == "p": # pico = 10^-12
amount_msat = amount // 10 # amount is in BTC * 10^-12
else:
# No multiplier means the amount is in BTC
amount_msat = amount * 100_000_000_000 # Convert BTC to msat
# Convert to target currency unit
if currency == "msat":
return amount_msat
elif currency == "sat":
return amount_msat // 1000
else:
raise LNURLError(f"Unsupported currency for Lightning: {currency}")
async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL.
@@ -291,9 +215,7 @@ async def raw_send_to_lnurl(
lnurl_data["callback_url"], final_amount
)
melt_quote_resp = await wallet.melt_quote(
invoice=bolt11_invoice, amount_msat=final_amount
)
melt_quote_resp = await wallet.melt_quote(invoice=bolt11_invoice)
if amount:
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)

View File

@@ -65,6 +65,28 @@ class Model(BaseModel):
return hash(self.id)
def _normalize_legacy_prompt_completion_pricing(
pricing: dict[str, object],
) -> dict[str, object]:
normalized = dict(pricing)
for field in ("prompt", "completion"):
raw_value = normalized.get(field)
if not isinstance(raw_value, (str, int, float)):
continue
try:
value = float(raw_value)
except (TypeError, ValueError):
continue
# Older admin overrides were occasionally saved in "per 1M tokens"
# units instead of the backend's expected per-token unit.
if value > 0.1:
normalized[field] = value / 1_000_000
return normalized
def _has_valid_pricing(model: dict) -> bool:
"""Check if model has valid pricing (not free, no negative values)."""
pricing = model.get("pricing", {})
@@ -143,14 +165,6 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
return []
def is_openrouter_upstream() -> bool:
try:
base = (settings.upstream_base_url or "").strip().rstrip("/")
except Exception:
return False
return base.lower() == "https://openrouter.ai/api/v1"
def _row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
@@ -164,6 +178,9 @@ def _row_to_model(
if apply_provider_fee and isinstance(pricing, dict):
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
if isinstance(pricing, dict):
pricing = _normalize_legacy_prompt_completion_pricing(pricing)
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
@@ -203,33 +220,11 @@ def _row_to_model(
return model
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
return {
"id": model.id,
"name": model.name,
"created": model.created,
"description": model.description,
"context_length": model.context_length,
"architecture": json.dumps(model.architecture.dict()),
"pricing": json.dumps(model.pricing.dict()),
"sats_pricing": json.dumps(model.sats_pricing.dict())
if model.sats_pricing
else None,
"per_request_limits": json.dumps(model.per_request_limits)
if model.per_request_limits is not None
else None,
"top_provider": json.dumps(model.top_provider.dict())
if model.top_provider is not None
else None,
"enabled": model.enabled,
"upstream_provider_id": model.upstream_provider_id,
}
async def list_models(
session: AsyncSession,
upstream_id: int,
include_disabled: bool = False,
apply_fees: bool = True,
) -> list[Model]:
from sqlmodel import select
@@ -247,7 +242,7 @@ async def list_models(
return [
_row_to_model(
r,
apply_provider_fee=True,
apply_provider_fee=apply_fees,
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
if r.upstream_provider_id in providers_by_id
else 1.01,
@@ -261,21 +256,6 @@ async def list_models(
]
async def get_model_by_id(
model_id: str, provider_id: int, session: AsyncSession
) -> Model | None:
from ..core.db import UpstreamProviderRow
row = await session.get(ModelRow, (model_id, provider_id))
if not row or not row.enabled:
return None
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider or not provider.enabled:
return None
provider_fee = provider.provider_fee if provider else 1.01
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
"""Calculate max costs in USD based on model context/token limits.

View File

@@ -16,6 +16,8 @@ from .core.db import (
create_session,
get_session,
)
from .core.exceptions import UpstreamError
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
check_token_balance,
@@ -31,7 +33,9 @@ proxy_router = APIRouter()
_upstreams: list[BaseUpstreamProvider] = []
_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)
@@ -68,8 +72,8 @@ def get_model_instance(model_id: str) -> Model | None:
return _model_instances.get(model_id.lower())
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
"""Get UpstreamProvider for model ID from global cache."""
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
"""Get UpstreamProvider list for model ID from global cache."""
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
)
upstream = get_provider_for_model(model_id)
if not upstream:
upstreams = get_provider_for_model(model_id)
if not upstreams:
return create_error_response(
"invalid_model",
f"No provider found for model '{model_id}'",
@@ -163,26 +167,52 @@ async def proxy(
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(
model=model_id, session=session, model_obj=model_obj
)
max_cost_for_model = await calculate_discounted_max_cost(
_max_cost_for_model, request_body_dict, model_obj=model_obj
)
# Ensure max_cost_for_model is at least the minimum allowed request cost
max_cost_for_model = max(max_cost_for_model, settings.min_request_msat)
check_token_balance(headers, request_body_dict, max_cost_for_model)
if x_cashu := headers.get("x-cashu", None):
if is_responses_api:
return await upstream.handle_x_cashu_responses(
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
)
last_error = None
for i, upstream in enumerate(upstreams):
try:
if is_responses_api:
return await upstream.handle_x_cashu_responses(
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):
key = await get_bearer_token_key(headers, path, session, auth)
key = await get_bearer_token_key(
headers, path, session, auth, max_cost_for_model
)
else:
if request.method not in ["GET"]:
@@ -194,77 +224,178 @@ async def proxy(
)
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:
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:
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
try:
try:
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
except UpstreamError:
# Let the outer UpstreamError handler manage retry/revert
raise
except Exception as e:
# Unexpected error (not an upstream failure) — revert and propagate
logger.error(
"Unexpected error in upstream request, reverting payment",
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:
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 the mapped error response generated earlier rather than masking with 502
return response
# If this was the last provider
if i == len(upstreams) - 1:
await revert_pay_for_request(key, session, max_cost_for_model)
return create_error_response(
"upstream_error", str(e), 502, request=request
)
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(
headers: dict, path: str, session: AsyncSession, auth: str
headers: dict, path: str, session: AsyncSession, auth: str, min_cost: int = 0
) -> ApiKey:
"""Handle bearer token authentication proxy requests."""
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
parts = auth.split()
bearer_key = parts[1] if len(parts) > 1 and parts[0].lower() == "bearer" else ""
refund_address = headers.get("Refund-LNURL", None)
key_expiry_time = headers.get("Key-Expiry-Time", None)
@@ -277,6 +408,7 @@ async def get_bearer_token_key(
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"min_cost": min_cost,
},
)
@@ -315,6 +447,7 @@ async def get_bearer_token_key(
session,
refund_address,
key_expiry_time, # type: ignore
min_cost=min_cost,
)
logger.info(
"Bearer token validated successfully",

View File

@@ -4,6 +4,7 @@ from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..payment.models import Model
class AzureUpstreamProvider(BaseUpstreamProvider):
@@ -58,19 +59,52 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
"platform_url": cls.platform_url,
}
def prepare_headers(self, request_headers: dict) -> dict:
"""Prepare headers for Azure OpenAI, adding api-key."""
headers = super().prepare_headers(request_headers)
if self.api_key:
headers["api-key"] = self.api_key
headers.pop("Authorization", None)
headers.pop("authorization", None)
return headers
def prepare_params(
self, path: str, query_params: Mapping[str, str] | None
) -> Mapping[str, str]:
"""Prepare query parameters for Azure OpenAI, adding API version.
Args:
path: Request path
query_params: Original query parameters from the client
Returns:
Query parameters dict with Azure API version added for chat completions
"""
"""Prepare query parameters for Azure OpenAI, adding API version."""
params = dict(query_params or {})
if path.endswith("chat/completions"):
params["api-version"] = self.api_version
version = (self.api_version or "").replace("\ufeff", "").strip()
if not version or version.lower() == "v1":
version = "2024-02-15-preview"
params["api-version"] = version
return params
def normalize_request_path(
self, path: str, model_obj: "Model | None" = None
) -> str:
"""Build Azure deployment-specific request path."""
clean_path = super().normalize_request_path(path, model_obj).lstrip("/")
if model_obj is None:
return clean_path
deployment_id = getattr(
model_obj, "canonical_slug", None
) or self.transform_model_name(model_obj.id)
deployment_id = deployment_id.split("/")[-1]
return f"openai/deployments/{deployment_id}/{clean_path}"
def get_request_base_url(
self, path: str, model_obj: "Model | None" = None
) -> str:
"""Use endpoint root, stripping accidental /openai/v1 suffix if present."""
base_url = self.base_url.rstrip("/")
marker = "/openai/v1"
if marker in base_url:
base_url = base_url.split(marker, 1)[0].rstrip("/")
return base_url
def transform_model_name(self, model_id: str) -> str:
"""Extract deployment name from model ID."""
if "/" in model_id:
return model_id.split("/")[-1]
return model_id

View File

@@ -5,20 +5,18 @@ import json
import re
import traceback
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Mapping
from typing import Mapping
import httpx
from fastapi import BackgroundTasks, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from sqlmodel import select
from ..auth import adjust_payment_for_tokens, revert_pay_for_request
from ..auth import adjust_payment_for_tokens
from ..core import get_logger
from ..core.db import ApiKey, AsyncSession, create_session
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session
from ..core.exceptions import UpstreamError
from ..payment.cost_calculation import (
CostData,
CostDataError,
@@ -31,6 +29,7 @@ from ..payment.models import (
Pricing,
_calculate_usd_max_costs,
_update_model_sats_pricing,
list_models,
)
from ..payment.price import sats_usd_price
from ..wallet import recieve_token, send_token
@@ -198,6 +197,23 @@ class BaseUpstreamProvider:
"""
return model_id
def normalize_request_path(self, path: str, model_obj: Model | None = None) -> str:
"""Normalize request path before forwarding to upstream."""
if path.startswith("v1/"):
return path.replace("v1/", "", 1)
return path
def get_request_base_url(
self, path: str, model_obj: Model | None = None
) -> str:
"""Get upstream base URL used when building forwarding URL."""
return self.base_url.rstrip("/")
def build_request_url(self, path: str, model_obj: Model | None = None) -> str:
"""Build full upstream URL from normalized path."""
clean_path = path.lstrip("/")
return f"{self.get_request_base_url(path, model_obj)}/{clean_path}"
def prepare_responses_request_body(
self, body: bytes | None, model_obj: Model
) -> bytes | None:
@@ -340,6 +356,20 @@ class BaseUpstreamProvider:
message = preview[:500]
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(
self, request: Request, path: str, upstream_response: httpx.Response
) -> Response:
@@ -412,7 +442,11 @@ class BaseUpstreamProvider:
)
async def handle_streaming_chat_completion(
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int
self,
response: httpx.Response,
key: ApiKey,
max_cost_for_model: int,
background_tasks: BackgroundTasks,
) -> StreamingResponse:
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
@@ -436,164 +470,133 @@ class BaseUpstreamProvider:
async def stream_with_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
stored_chunks: list[bytes] = []
usage_finalized: bool = False
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
if usage_finalized:
return None
return
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
logger.warning(
"Key not found when finalizing streaming payment",
extra={"key_hash": key.hashed_key[:8] + "..."},
)
usage_finalized = True
return None
return
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key, fallback, new_session, max_cost_for_model
await adjust_payment_for_tokens(
fresh_key,
{"model": last_model_seen or "unknown", "usage": None},
new_session,
max_cost_for_model,
)
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:
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(
"Streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
},
)
stripped_part = part.strip()
if not stripped_part:
continue
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
last_model_seen = str(data.get("model"))
if isinstance(data, dict) and isinstance(
data.get("usage"), dict
):
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
cost_data = (
await adjust_payment_for_tokens(
fresh_key,
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] + "...",
},
if stripped_part == b"[DONE]":
done_seen = True
continue
try:
obj = json.loads(part)
if isinstance(obj, dict):
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if isinstance(obj.get("usage"), dict):
# Hold this chunk back to merge cost later
usage_chunk_data = obj
continue
except json.JSONDecodeError:
pass
prefix = (
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
)
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,
)
remaining_balance_msats = fresh_key.balance
# Merge cost into usage
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
usage_chunk_data["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
usage_chunk_data["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
)
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
}
usage_chunk_data["metadata"]["routstr"]["cost"][
"sats_cost"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["metadata"]["routstr"]["cost"][
"remaining_balance_msats"
] = remaining_balance_msats
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception 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:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
await finalize_db_only()
if done_seen:
yield b"data: [DONE]\n\n"
except Exception as stream_error:
logger.warning(
"Streaming interrupted; finalizing without usage",
"Streaming interrupted; finalizing in background",
extra={
"error": str(stream_error),
"error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
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
response_headers = dict(response.headers)
@@ -633,6 +636,7 @@ class BaseUpstreamProvider:
},
)
content: bytes | None = None
try:
content = await response.aread()
response_json = json.loads(content)
@@ -649,7 +653,32 @@ class BaseUpstreamProvider:
cost_data = await adjust_payment_for_tokens(
key, response_json, session, deducted_max_cost
)
await session.refresh(key)
remaining_balance_msats = key.balance
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
response_json["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
response_json["cost"] = cost_data
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.info(
"Payment adjustment completed for non-streaming",
@@ -734,180 +763,154 @@ class BaseUpstreamProvider:
async def stream_with_responses_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
stored_chunks: list[bytes] = []
usage_finalized: bool = False
last_model_seen: str | None = None
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
if usage_finalized:
return None
return
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
logger.warning(
"Key not found when finalizing Responses API streaming payment",
extra={"key_hash": key.hashed_key[:8] + "..."},
)
usage_finalized = True
return None
return
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key, fallback, new_session, max_cost_for_model
await adjust_payment_for_tokens(
fresh_key,
{"model": last_model_seen or "unknown", "usage": None},
new_session,
max_cost_for_model,
)
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:
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(
"Responses API streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
"reasoning_tokens": reasoning_tokens,
},
)
stripped_part = part.strip()
if not stripped_part:
continue
# Process final usage data
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
last_model_seen = str(data.get("model"))
if isinstance(data, dict) and isinstance(
data.get("usage"), dict
):
# Include reasoning tokens in usage calculation
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
if stripped_part == b"[DONE]":
done_seen = True
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
)
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 Responses API streaming",
extra={
"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] + "...",
},
# Responses API usage is in response.completed/incomplete events
chunk_type = obj.get("type", "")
if chunk_type in (
"response.completed",
"response.incomplete",
):
usage_chunk_data = obj
continue
except json.JSONDecodeError:
pass
prefix = (
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
)
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,
)
remaining_balance_msats = fresh_key.balance
# 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)
)
usage_chunk_data["response"]["usage"][
"cost_sats"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["response"]["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
elif "usage" in usage_chunk_data:
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
usage_chunk_data["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
usage_chunk_data["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
)
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
}
usage_chunk_data["metadata"]["routstr"]["cost"][
"sats_cost"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["metadata"]["routstr"]["cost"][
"remaining_balance_msats"
] = remaining_balance_msats
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception:
# Fallback: yield original usage chunk if adjustment fails
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if not usage_finalized:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
await finalize_db_only()
if done_seen:
yield b"data: [DONE]\n\n"
except Exception as stream_error:
logger.warning(
"Responses API streaming interrupted; finalizing without usage",
"Responses API streaming interrupted; finalizing in background",
extra={
"error": str(stream_error),
"error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
if not usage_finalized:
await finalize_without_usage()
await finalize_db_only()
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -947,6 +950,7 @@ class BaseUpstreamProvider:
},
)
content: bytes | None = None
try:
content = await response.aread()
response_json = json.loads(content)
@@ -966,7 +970,32 @@ class BaseUpstreamProvider:
cost_data = await adjust_payment_for_tokens(
key, response_json, session, deducted_max_cost
)
await session.refresh(key)
remaining_balance_msats = key.balance
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
response_json["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
response_json["cost"] = cost_data
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.info(
"Payment adjustment completed for non-streaming Responses API",
@@ -1089,10 +1118,8 @@ class BaseUpstreamProvider:
Returns:
Response or StreamingResponse from upstream with cost tracking
"""
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{self.base_url}/{path}"
path = self.normalize_request_path(path, model_obj)
url = self.build_request_url(path, model_obj)
transformed_body = self.prepare_request_body(request_body, model_obj)
@@ -1148,6 +1175,14 @@ class BaseUpstreamProvider:
)
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:
mapped_error = await self.map_upstream_error_response(
request, path, response
@@ -1193,12 +1228,12 @@ class BaseUpstreamProvider:
)
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model, background_tasks
)
result.background = background_tasks
return result
@@ -1238,6 +1273,9 @@ class BaseUpstreamProvider:
background=background_tasks,
)
except UpstreamError:
raise
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
@@ -1256,8 +1294,7 @@ class BaseUpstreamProvider:
},
)
await revert_pay_for_request(key, session, max_cost_for_model)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
@@ -1267,9 +1304,7 @@ class BaseUpstreamProvider:
else:
error_message = f"Error connecting to upstream service: {error_type}"
return create_error_response(
"upstream_error", error_message, 502, request=request
)
raise UpstreamError(error_message, status_code=502)
except Exception as exc:
await client.aclose()
@@ -1289,13 +1324,9 @@ class BaseUpstreamProvider:
},
)
await revert_pay_for_request(key, session, max_cost_for_model)
return create_error_response(
"internal_error",
"An unexpected server error occurred",
500,
request=request,
# Don't revert here — proxy.py owns payment revert to avoid double-revert
raise UpstreamError(
"An unexpected server error occurred", status_code=500
)
async def forward_responses_request(
@@ -1324,11 +1355,8 @@ class BaseUpstreamProvider:
Returns:
Response or StreamingResponse from upstream with cost tracking
"""
# Remove v1/ prefix if present for Responses API
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{self.base_url}/{path}"
path = self.normalize_request_path(path, model_obj)
url = self.build_request_url(path, model_obj)
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
@@ -1384,6 +1412,14 @@ class BaseUpstreamProvider:
)
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:
mapped_error = await self.map_upstream_error_response(
request, path, response
@@ -1451,6 +1487,9 @@ class BaseUpstreamProvider:
background=background_tasks,
)
except UpstreamError:
raise
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
@@ -1469,8 +1508,7 @@ class BaseUpstreamProvider:
},
)
await revert_pay_for_request(key, session, max_cost_for_model)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
@@ -1480,9 +1518,7 @@ class BaseUpstreamProvider:
else:
error_message = f"Error connecting to upstream service: {error_type}"
return create_error_response(
"upstream_error", error_message, 502, request=request
)
raise UpstreamError(error_message, status_code=502)
except Exception as exc:
await client.aclose()
@@ -1502,13 +1538,9 @@ class BaseUpstreamProvider:
},
)
await revert_pay_for_request(key, session, max_cost_for_model)
return create_error_response(
"internal_error",
"An unexpected server error occurred",
500,
request=request,
# Don't revert here — proxy.py owns payment revert to avoid double-revert
raise UpstreamError(
"An unexpected server error occurred", status_code=500
)
async def forward_get_request(
@@ -1527,10 +1559,8 @@ class BaseUpstreamProvider:
Returns:
StreamingResponse from upstream
"""
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{self.base_url}/{path}"
path = self.normalize_request_path(path)
url = self.build_request_url(path)
logger.info(
"Forwarding GET request to upstream",
@@ -3057,18 +3087,44 @@ class BaseUpstreamProvider:
async def refresh_models_cache(self) -> None:
"""Refresh the in-memory models cache from upstream API."""
try:
models = await self.fetch_models()
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
async with create_session() as session:
stmt = select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == self.base_url,
UpstreamProviderRow.api_key == self.api_key
)
result = await session.exec(stmt)
try:
sats_to_usd = sats_usd_price()
self._models_cache = [
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
]
except Exception:
self._models_cache = models_with_fees
# .first() returns the object or None if not found
provider = result.first()
if not provider or not provider.id:
raise HTTPException(status_code=404, detail="Provider not found")
self._models_by_id = {m.id: m for m in self._models_cache}
db_models = await list_models(
session=session,
upstream_id=provider.id,
include_disabled=False,
apply_fees=False,
)
db_model_ids: set[str] = {model.id for model in db_models}
models = await self.fetch_models()
model_ids = [model.id for model in models]
diff = set(db_model_ids) - set(model_ids)
for db_model_id in diff:
found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id))
models.append(found_db_model)
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
try:
sats_to_usd = sats_usd_price()
self._models_cache = [
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
]
except Exception:
self._models_cache = models_with_fees
self._models_by_id = {m.id: m for m in self._models_cache}
except Exception as e:
logger.error(

View File

@@ -259,7 +259,15 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
cost_data = await adjust_payment_for_tokens(
key, openai_format_response, session, max_cost_for_model
)
await session.refresh(key)
remaining_balance_msats = key.balance
openai_format_response["cost"] = cost_data
openai_format_response["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
openai_format_response["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
logger.info(
"Gemini non-streaming payment completed",

View File

@@ -79,6 +79,22 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
data = response.json()
models_list = []
def parse_pricing_value(value: object) -> float:
if isinstance(value, dict):
value = value.get("usd", 0.0)
if isinstance(value, str):
try:
value = float(value)
except ValueError:
return 0.0
if isinstance(value, (int, float)):
return float(value)
return 0.0
for model_data in data.get("data", []):
model_id = model_data.get("id", "")
if not model_id:
@@ -111,8 +127,8 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
input_pricing = pricing_info.get("input", {})
output_pricing = pricing_info.get("output", {})
prompt_price = input_pricing.get("usd", 0.001) / 1000000
completion_price = output_pricing.get("usd", 0.001) / 1000000
prompt_price = parse_pricing_value(input_pricing) / 1_000_000
completion_price = parse_pricing_value(output_pricing) / 1_000_000
capabilities = model_spec.get("capabilities", {})
input_modalities = ["text"]

View File

@@ -205,6 +205,9 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
provider = _instantiate_provider(provider_row)
if provider:
# Keep provider DB id on runtime instance so model mapping can
# bind DB overrides to the correct upstream.
setattr(provider, "db_id", provider_row.id)
await provider.refresh_models_cache()
logger.debug(
f"Initialized {provider_row.provider_type} provider",
@@ -236,7 +239,7 @@ async def _seed_providers_from_settings(
from . import upstream_provider_classes
providers_to_add: list[UpstreamProviderRow] = []
seeded_base_urls: set[str] = set()
seeded_provider_keys: set[tuple[str, str]] = set()
provider_classes_by_type = {
cls.provider_type: cls
@@ -261,7 +264,8 @@ async def _seed_providers_from_settings(
base_url = provider_class.default_base_url # type: ignore[attr-defined]
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == base_url
UpstreamProviderRow.base_url == base_url,
UpstreamProviderRow.api_key == api_key,
)
)
if not result.first():
@@ -273,13 +277,15 @@ async def _seed_providers_from_settings(
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")
if ollama_base_url:
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "")
result = await session.exec(
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():
@@ -287,18 +293,20 @@ async def _seed_providers_from_settings(
UpstreamProviderRow(
provider_type="ollama",
base_url=ollama_base_url,
api_key=os.environ.get("OLLAMA_API_KEY", ""),
api_key=ollama_api_key,
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:
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(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == base_url
UpstreamProviderRow.base_url == base_url,
UpstreamProviderRow.api_key == api_key,
)
)
if not result.first():
@@ -306,19 +314,21 @@ async def _seed_providers_from_settings(
UpstreamProviderRow(
provider_type="azure",
base_url=base_url,
api_key=settings.upstream_api_key,
api_key=api_key,
api_version=settings.chat_completions_api_version,
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:
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(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == base_url
UpstreamProviderRow.base_url == base_url,
UpstreamProviderRow.api_key == api_key,
)
)
if not result.first():
@@ -326,11 +336,11 @@ async def _seed_providers_from_settings(
UpstreamProviderRow(
provider_type="custom",
base_url=base_url,
api_key=settings.upstream_api_key,
api_key=api_key,
enabled=True,
)
)
seeded_base_urls.add(base_url)
seeded_provider_keys.add((base_url, api_key))
for provider in providers_to_add:
session.add(provider)

View File

@@ -3,13 +3,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..core.db import UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
@@ -67,38 +65,11 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
"""Strip 'ollama/' prefix for Ollama API compatibility."""
return model_id.removeprefix("ollama/")
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
"""Override to use OpenAI-compatible endpoint for proxy requests."""
if path.startswith("v1/"):
path = path.replace("v1/", "")
original_base_url = self.base_url
self.base_url = f"{self.base_url}/v1"
try:
result = await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
return result
finally:
self.base_url = original_base_url
def get_request_base_url(
self, path: str, model_obj: Model | None = None
) -> str:
"""Route proxy traffic through Ollama's OpenAI-compatible /v1 endpoint."""
return f"{self.base_url.rstrip('/')}/v1"
async def fetch_models(self) -> list[Model]:
"""Fetch models from Ollama API using /api/tags endpoint."""

View File

@@ -196,6 +196,37 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
)
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]:
"""Create a new PPQ.AI account.

View File

@@ -1,5 +1,6 @@
import asyncio
import math
import time
from typing import TypedDict
from cashu.core.base import Proof, Token
@@ -158,6 +159,14 @@ async def credit_balance(
_wallets: dict[str, Wallet] = {}
_balances_cache_ttl_seconds = 300.0
_balances_cache: dict[
tuple[str, ...], tuple[float, tuple[list["BalanceDetail"], int, int, int]]
] = {}
_balances_refresh_tasks: dict[
tuple[str, ...], asyncio.Task[tuple[list["BalanceDetail"], int, int, int]]
] = {}
_balances_cache_lock = asyncio.Lock()
async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet:
@@ -226,6 +235,12 @@ async def fetch_all_balances(
"""
if units is None:
units = ["sat", "msat"]
units_key = tuple(units)
now = time.time()
cached = _balances_cache.get(units_key)
if cached and cached[0] > now:
return cached[1]
async def fetch_balance(
session: db.AsyncSession, mint_url: str, unit: str
@@ -261,47 +276,71 @@ async def fetch_all_balances(
}
return error_result
# Create tasks for all mint/unit combinations
async with db.create_session() as session:
tasks = [
fetch_balance(session, mint_url, unit)
for mint_url in settings.cashu_mints
for unit in units
]
async def compute_balances() -> tuple[list[BalanceDetail], int, int, int]:
# Create tasks for all mint/unit combinations
async with db.create_session() as session:
tasks = [
fetch_balance(session, mint_url, unit)
for mint_url in settings.cashu_mints
for unit in units
]
# Run all tasks concurrently
balance_details = list(await asyncio.gather(*tasks))
# Run all tasks concurrently
balance_details = list(await asyncio.gather(*tasks))
# Calculate totals
total_wallet_balance_sats = 0
total_user_balance_sats = 0
# Calculate totals
total_wallet_balance_sats = 0
total_user_balance_sats = 0
for detail in balance_details:
if not detail.get("error"):
# Convert to sats for total calculation
unit = detail["unit"]
proofs_balance_sats = (
detail["wallet_balance"]
if unit == "sat"
else detail["wallet_balance"] // 1000
)
user_balance_sats = (
detail["user_balance"]
if unit == "sat"
else detail["user_balance"] // 1000
)
for detail in balance_details:
if not detail.get("error"):
# Convert to sats for total calculation
unit = detail["unit"]
proofs_balance_sats = (
detail["wallet_balance"]
if unit == "sat"
else detail["wallet_balance"] // 1000
)
user_balance_sats = (
detail["user_balance"]
if unit == "sat"
else detail["user_balance"] // 1000
)
total_wallet_balance_sats += proofs_balance_sats
total_user_balance_sats += user_balance_sats
total_wallet_balance_sats += proofs_balance_sats
total_user_balance_sats += user_balance_sats
owner_balance = total_wallet_balance_sats - total_user_balance_sats
owner_balance = total_wallet_balance_sats - total_user_balance_sats
return (
balance_details,
total_wallet_balance_sats,
total_user_balance_sats,
owner_balance,
)
return (
balance_details,
total_wallet_balance_sats,
total_user_balance_sats,
owner_balance,
)
async with _balances_cache_lock:
now = time.time()
cached = _balances_cache.get(units_key)
if cached and cached[0] > now:
return cached[1]
refresh_task = _balances_refresh_tasks.get(units_key)
if refresh_task is None or refresh_task.done():
refresh_task = asyncio.create_task(compute_balances())
_balances_refresh_tasks[units_key] = refresh_task
result = await refresh_task
async with _balances_cache_lock:
_balances_cache[units_key] = (
time.time() + _balances_cache_ttl_seconds,
result,
)
current_task = _balances_refresh_tasks.get(units_key)
if current_task is refresh_task and refresh_task.done():
_balances_refresh_tasks.pop(units_key, None)
return result
async def periodic_payout() -> None:
@@ -319,6 +358,7 @@ async def periodic_payout() -> None:
wallet, mint_url, unit, not_reserved=True
)
proofs = await slow_filter_spend_proofs(proofs, wallet)
await asyncio.sleep(5)
user_balance = await db.balances_for_mint_and_unit(
session, mint_url, unit
)
@@ -344,8 +384,6 @@ async def periodic_payout() -> None:
"amount_received": amount_received,
},
)
await asyncio.sleep(5)
except Exception as e:
logger.error(
f"Error sending payout: {type(e).__name__}",

View File

@@ -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)

View File

@@ -0,0 +1,97 @@
from typing import Any
import pytest
from httpx import AsyncClient
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_info_returns_child_keys(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
integration_session: Any,
) -> None:
"""Test that GET /v1/wallet/info returns child keys for a parent key"""
# 1. Get parent info to find its hashed_key
response = await authenticated_client.get("/v1/wallet/info")
assert response.status_code == 200
parent_data = response.json()
parent_data["api_key"]
# 2. Create child keys for this parent
# We need to use the parent's authentication for this
child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"}
create_response = await authenticated_client.post(
"/v1/wallet/child-key", json=child_payload
)
assert create_response.status_code == 200
create_data = create_response.json()
child_keys = create_data["api_keys"]
assert len(child_keys) == 2
# 3. Call /info again and check for child_keys
info_response = await authenticated_client.get("/v1/wallet/info")
assert info_response.status_code == 200
info_data = info_response.json()
assert "child_keys" in info_data
assert len(info_data["child_keys"]) == 2
# Verify child key details
for ck in info_data["child_keys"]:
assert ck["api_key"] in child_keys
assert ck["balance_limit"] == 1000
assert ck["balance_limit_reset"] == "daily"
assert "total_spent" in ck
assert "total_requests" in ck
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_info_child_key_no_child_keys(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
integration_session: Any,
) -> None:
"""Test that GET /v1/wallet/info for a child key does NOT return child_keys"""
# 1. Create a child key
child_payload = {"count": 1}
create_response = await authenticated_client.post(
"/v1/wallet/child-key", json=child_payload
)
assert create_response.status_code == 200
child_key = create_response.json()["api_keys"][0]
# 2. Use the child key to get its info
integration_client.headers["Authorization"] = f"Bearer {child_key}"
info_response = await integration_client.get("/v1/wallet/info")
assert info_response.status_code == 200
info_data = info_response.json()
assert info_data["is_child"] is True
assert "child_keys" not in info_data
@pytest.mark.integration
@pytest.mark.asyncio
async def test_account_info_root_returns_child_keys(
authenticated_client: AsyncClient,
) -> None:
"""Test that GET / returns child keys for a parent key (root endpoint)"""
# 1. Create a child key
child_payload = {"count": 1}
await authenticated_client.post("/v1/wallet/child-key", json=child_payload)
# 2. Call root endpoint /v1/balance/
# Note: routstr/balance.py defines router = APIRouter()
# and it is included in balance_router with prefix /v1/balance
# The endpoint is @router.get("/")
response = await authenticated_client.get("/v1/balance/")
assert response.status_code == 200
data = response.json()
assert "child_keys" in data
assert len(data["child_keys"]) >= 1

View File

@@ -264,13 +264,13 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) -
async def test_admin_endpoint_unauthenticated(
integration_client: AsyncClient, db_snapshot: Any
) -> None:
"""Test GET /admin/ endpoint redirects to /"""
"""Test unauthenticated access to admin settings endpoint is rejected."""
await db_snapshot.capture()
response = await integration_client.get("/admin/")
response = await integration_client.get("/admin/api/settings")
assert response.status_code == 307
assert response.headers.get("location") == "/"
assert response.status_code == 401
assert response.headers.get("www-authenticate") == "Bearer"
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["added"]) == 0

View File

@@ -0,0 +1,142 @@
import time
from datetime import datetime, timedelta
import pytest
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
from routstr.core.db import ApiKey
@pytest.mark.asyncio
async def test_key_validity_date(integration_session: AsyncSession) -> None:
# 1. Create a key that is expired
expired_time = int(time.time()) - 3600
key = ApiKey(hashed_key="expired_key", balance=1000, validity_date=expired_time)
integration_session.add(key)
await integration_session.commit()
# 2. Try to pay for a request - should fail
with pytest.raises(Exception) as excinfo:
await pay_for_request(key, 100, integration_session)
assert "expired" in str(excinfo.value).lower()
@pytest.mark.asyncio
async def test_key_balance_limit(integration_session: AsyncSession) -> None:
# 1. Create a key with a balance limit
key = ApiKey(
hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450
)
integration_session.add(key)
await integration_session.commit()
# 2. Try to pay for a request that exceeds the limit
with pytest.raises(Exception) as excinfo:
await pay_for_request(key, 100, integration_session)
assert "limit exceeded" in str(excinfo.value).lower()
# 3. Try to pay for a request that fits
await pay_for_request(key, 50, integration_session)
await integration_session.refresh(key)
# Note: total_spent is updated in adjust_payment_for_tokens,
# but pay_for_request checks it.
# In our current logic, pay_for_request checks (total_spent + cost) > balance_limit.
@pytest.mark.asyncio
async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None:
# 1. Create a key with a daily reset policy and old reset date
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
key = ApiKey(
hashed_key="daily_reset_key",
balance=10000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=900,
)
integration_session.add(key)
await integration_session.commit()
# 2. Pay for a request - should trigger reset first because it's a new day
# Request is 200, total_spent is 900. 900+200 > 1000,
# but reset should happen making total_spent 0, then 0+200 < 1000.
await pay_for_request(key, 200, integration_session)
await integration_session.refresh(key)
assert key.total_spent == 0 # Reset in pay_for_request happens before charging
# Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests,
# but total_spent is updated in adjust_payment_for_tokens.
# However, the reset logic sets total_spent to 0.
assert key.balance_limit_reset_date is not None
assert key.balance_limit_reset_date > yesterday
@pytest.mark.asyncio
async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None:
# 1. Create multiple keys needing reset
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
key1 = ApiKey(
hashed_key="job_reset_key_1",
balance=1000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=500,
)
key2 = ApiKey(
hashed_key="job_reset_key_2",
balance=1000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=800,
)
integration_session.add(key1)
integration_session.add(key2)
await integration_session.commit()
# 2. Run the periodic reset logic manually (mocking the background task loop)
# We can't easily run the actual loop because it has a sleep,
# but we can test the logic inside.
# Implementation of periodic_key_reset logic for testing:
stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) # noqa: E711
keys = (await integration_session.exec(stmt)).all()
now = int(time.time())
for k in keys:
if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]:
k.total_spent = 0
k.balance_limit_reset_date = now
integration_session.add(k)
await integration_session.commit()
# 3. Verify resets
await integration_session.refresh(key1)
await integration_session.refresh(key2)
assert key1.total_spent == 0
assert key2.total_spent == 0
@pytest.mark.asyncio
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
# This requires mocking the router call or testing the logic in balance.py
from routstr.balance import ApiKey
key = ApiKey(hashed_key="refund_test_key", balance=1000, reserved_balance=100)
integration_session.add(key)
await integration_session.commit()
# Logic from refund_wallet_endpoint:
key.balance = 0
key.reserved_balance = 0
integration_session.add(key)
await integration_session.commit()
# Verify key still exists
fetched_key = await integration_session.get(ApiKey, "refund_test_key")
assert fetched_key is not None
assert fetched_key.balance == 0
assert fetched_key.reserved_balance == 0

View File

@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.discovery import _PROVIDERS_CACHE
from routstr.nostr.discovery import _PROVIDERS_CACHE
from .utils import ResponseValidator
@@ -71,9 +71,10 @@ async def test_providers_endpoint_default_response(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
# Configure mock to return appropriate responses
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
@@ -135,9 +136,10 @@ async def test_providers_endpoint_with_include_json(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {
"status_code": 200,
"json": mock_provider_response,
@@ -209,9 +211,10 @@ async def test_providers_data_structure_validation(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = mock_health_response
response = await integration_client.get("/v1/providers/?include_json=true")
@@ -256,7 +259,8 @@ async def test_providers_endpoint_no_providers_found(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
response = await integration_client.get("/v1/providers/")
@@ -317,10 +321,11 @@ async def test_providers_endpoint_offline_providers(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch(
"routstr.discovery.fetch_provider_health",
"routstr.nostr.discovery.fetch_provider_health",
side_effect=mock_fetch_provider_health,
):
response = await integration_client.get("/v1/providers/?include_json=true")
@@ -386,9 +391,10 @@ async def test_providers_endpoint_duplicate_urls(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {
"status_code": 200,
"endpoint": "root",
@@ -425,7 +431,8 @@ async def test_providers_endpoint_nostr_relay_failures(
raise Exception("Connection to relay failed")
with patch(
"routstr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
"routstr.nostr.discovery.query_nostr_relay_for_providers",
side_effect=failing_query,
):
response = await integration_client.get("/v1/providers/")
@@ -463,9 +470,10 @@ async def test_providers_endpoint_malformed_urls(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
response = await integration_client.get("/v1/providers/")
@@ -495,9 +503,10 @@ async def test_providers_endpoint_response_format(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test default format
@@ -545,9 +554,10 @@ async def test_providers_endpoint_concurrent_requests(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Create concurrent requests
@@ -587,9 +597,10 @@ async def test_providers_endpoint_parameter_validation(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test various parameter values
@@ -639,9 +650,10 @@ async def test_no_database_changes_during_provider_operations(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Make multiple requests with different parameters

View File

@@ -133,13 +133,16 @@ async def test_reserved_balance_with_successful_requests(
@pytest.mark.asyncio
async def test_insufficient_reserved_balance_for_revert(
async def test_revert_with_zero_reserved_balance_is_noop(
integration_session: AsyncSession,
) -> None:
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
"""Test that revert_pay_for_request is a no-op when reserved_balance is 0.
Previously this would drive reserved_balance negative. With the floor guard,
it should return False and leave reserved_balance at 0.
"""
from routstr.auth import revert_pay_for_request
# Create key with zero reserved balance
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
@@ -149,17 +152,211 @@ async def test_insufficient_reserved_balance_for_revert(
integration_session.add(test_key)
await integration_session.commit()
# Try to revert more than available
# Note: Current implementation allows reserved_balance to go negative
await revert_pay_for_request(test_key, integration_session, 100)
# Try to revert more than available — should be a no-op
result = await revert_pay_for_request(test_key, integration_session, 100)
# Refresh to get updated values
await integration_session.refresh(test_key)
# Current implementation allows negative reserved balance
assert test_key.reserved_balance == -100, (
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
assert result is False, "Revert should return False when reservation already released"
assert test_key.reserved_balance == 0, (
f"Reserved balance should remain 0, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == -1, (
f"Expected total_requests to be -1, got: {test_key.total_requests}"
assert test_key.total_requests == 0, (
f"Total requests should remain 0, got: {test_key.total_requests}"
)
@pytest.mark.asyncio
async def test_revert_with_sufficient_reserved_balance_succeeds(
integration_session: AsyncSession,
) -> None:
"""Test that revert_pay_for_request works correctly when there is enough reserved balance."""
from routstr.auth import revert_pay_for_request
unique_key = f"test_revert_ok_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
balance=5000,
reserved_balance=500,
total_requests=3,
)
integration_session.add(test_key)
await integration_session.commit()
result = await revert_pay_for_request(test_key, integration_session, 500)
await integration_session.refresh(test_key)
assert result is True, "Revert should return True on success"
assert test_key.reserved_balance == 0, (
f"Reserved balance should be 0, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == 2, (
f"Total requests should be 2, got: {test_key.total_requests}"
)
assert test_key.balance == 5000, "Balance should not change on revert"
@pytest.mark.asyncio
async def test_revert_partial_reserved_balance_is_noop(
integration_session: AsyncSession,
) -> None:
"""Test that reverting more than the current reserved_balance is a no-op."""
from routstr.auth import revert_pay_for_request
unique_key = f"test_revert_partial_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
balance=5000,
reserved_balance=50,
total_requests=1,
)
integration_session.add(test_key)
await integration_session.commit()
# Try to revert 500 when only 50 is reserved — should be no-op
result = await revert_pay_for_request(test_key, integration_session, 500)
await integration_session.refresh(test_key)
assert result is False, "Revert should fail when cost > reserved_balance"
assert test_key.reserved_balance == 50, (
f"Reserved balance should stay at 50, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == 1, (
f"Total requests should stay at 1, got: {test_key.total_requests}"
)
@pytest.mark.asyncio
async def test_double_revert_prevented(
integration_session: AsyncSession,
) -> None:
"""Test that calling revert twice doesn't drive reserved_balance negative.
This simulates the double-revert scenario where both upstream/base.py
and proxy.py attempt to revert the same reservation.
"""
from routstr.auth import revert_pay_for_request
unique_key = f"test_double_revert_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
balance=10000,
reserved_balance=500,
total_requests=5,
)
integration_session.add(test_key)
await integration_session.commit()
# First revert — should succeed
result1 = await revert_pay_for_request(test_key, integration_session, 500)
await integration_session.refresh(test_key)
assert result1 is True
assert test_key.reserved_balance == 0
assert test_key.total_requests == 4
# Second revert of the same amount — should be no-op
result2 = await revert_pay_for_request(test_key, integration_session, 500)
await integration_session.refresh(test_key)
assert result2 is False, "Second revert should be a no-op"
assert test_key.reserved_balance == 0, (
f"Reserved balance should stay 0, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == 4, (
f"Total requests should stay 4, got: {test_key.total_requests}"
)
@pytest.mark.asyncio
async def test_sequential_reverts_never_go_negative(
integration_session: AsyncSession,
) -> None:
"""Test that multiple reverts don't cause negative reserved_balance.
Simulates the double-revert scenario where multiple code paths
attempt to revert the same reservation.
"""
from routstr.auth import revert_pay_for_request
unique_key = f"test_multi_revert_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
balance=10000,
reserved_balance=500,
total_requests=5,
)
integration_session.add(test_key)
await integration_session.commit()
# Run 5 sequential reverts for the same 500 reservation
results = []
for _ in range(5):
r = await revert_pay_for_request(test_key, integration_session, 500)
results.append(r)
await integration_session.refresh(test_key)
# Exactly one should succeed, rest should be no-ops
success_count = sum(1 for r in results if r is True)
assert success_count == 1, (
f"Exactly one revert should succeed, got {success_count} successes"
)
assert test_key.reserved_balance == 0, (
f"Reserved balance should be 0, got: {test_key.reserved_balance}"
)
assert test_key.reserved_balance >= 0, (
f"Reserved balance went negative: {test_key.reserved_balance}"
)
@pytest.mark.asyncio
async def test_child_key_revert_floor_guard(
integration_session: AsyncSession,
) -> None:
"""Test that child key reserved_balance also has floor guard on revert."""
from routstr.auth import revert_pay_for_request
parent_key_hash = f"test_parent_{uuid.uuid4().hex[:8]}"
child_key_hash = f"test_child_{uuid.uuid4().hex[:8]}"
parent_key = ApiKey(
hashed_key=parent_key_hash,
balance=10000,
reserved_balance=500,
total_requests=3,
)
child_key = ApiKey(
hashed_key=child_key_hash,
balance=0,
reserved_balance=500,
total_requests=3,
parent_key_hash=parent_key_hash,
)
integration_session.add(parent_key)
integration_session.add(child_key)
await integration_session.commit()
# First revert succeeds
result1 = await revert_pay_for_request(child_key, integration_session, 500)
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key)
assert result1 is True
assert parent_key.reserved_balance == 0
assert child_key.reserved_balance == 0
# Second revert is a no-op for both parent and child
result2 = await revert_pay_for_request(child_key, integration_session, 500)
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key)
assert result2 is False
assert parent_key.reserved_balance == 0, (
f"Parent reserved_balance should stay 0, got: {parent_key.reserved_balance}"
)
assert child_key.reserved_balance == 0, (
f"Child reserved_balance should stay 0, got: {child_key.reserved_balance}"
)

View File

@@ -65,9 +65,10 @@ async def test_full_balance_refund_returns_cashu_token(
except Exception as e:
pytest.fail(f"Invalid Cashu token format: {e}")
# Try to use the API key - should fail since it's been deleted
# Try to use the API key - should still work but have 0 balance
response = await authenticated_client.get("/v1/wallet/")
assert response.status_code == 401
assert response.status_code == 200
assert response.json()["balance"] == 0
# The refund token has been validated above by decoding it
# The API key deletion has been verified by the 401 response
@@ -261,17 +262,16 @@ async def test_database_state_after_refund(
response = await authenticated_client.post("/v1/wallet/refund")
assert response.status_code == 200
# Verify key is deleted after refund
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
assert result.scalar_one_or_none() is None
# Refresh the key to get the updated balance from the database
await integration_session.refresh(key_before)
# Count total keys to ensure only the specific one was deleted
# Verify key balance is 0 after refund
assert key_before.balance == 0
# Count total keys to ensure it wasn't deleted
result = await integration_session.execute(select(ApiKey))
remaining_keys = result.scalars().all()
# Should have no keys left (assuming clean test environment)
assert len(remaining_keys) == 0
assert len(remaining_keys) == 1
@pytest.mark.integration
@@ -388,9 +388,10 @@ async def test_refund_during_active_usage(
# Refund should succeed
assert refund_response.status_code == 200
# Further usage should fail
# Further usage should return 200 but with 0 balance
response = await authenticated_client.get("/v1/wallet/")
assert response.status_code == 401
assert response.status_code == 200
assert response.json()["balance"] == 0
@pytest.mark.integration
@@ -535,6 +536,3 @@ async def test_refund_with_expired_key(
# Should still allow manual refund
assert response.status_code == 200
assert response.json()["recipient"] == "expired@ln.address"

View File

@@ -1,16 +1,19 @@
"""Tests for the model prioritization algorithm."""
import os
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
# Set required env vars before importing
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.algorithm import ( # noqa: E402
calculate_model_cost_score,
create_model_mappings,
get_provider_penalty,
should_prefer_model,
)
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
@@ -46,11 +49,21 @@ def create_test_model(
)
def create_test_provider(name: str, base_url: str = "http://test.com") -> Mock:
def create_test_provider(
name: str,
base_url: str = "http://test.com",
*,
db_id: int | None = None,
models: list[Model] | None = None,
upstream_name: str | None = None,
) -> Mock:
"""Helper to create a test provider mock."""
provider = Mock()
provider.provider_type = name
provider.base_url = base_url
provider.db_id = db_id
provider.upstream_name = upstream_name or name
provider.get_cached_models.return_value = models or []
return provider
@@ -102,98 +115,78 @@ def test_get_provider_penalty_openrouter() -> None:
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
def test_create_model_mappings_includes_db_override_for_missing_cached_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Model overrides should still map when provider discovery misses the model."""
provider = create_test_provider(
"azure",
"https://example.openai.azure.com/openai/v1",
db_id=7,
models=[],
)
override_model = create_test_model("azure/gpt-4o")
override_model.canonical_slug = "azure-deployment"
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
return override_model
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
override_row = SimpleNamespace(id="azure/gpt-4o", upstream_provider_id=7, enabled=True)
model_instances, provider_map, unique_models = create_model_mappings(
upstreams=[provider],
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
disabled_model_ids=set(),
)
provider1 = create_test_provider("provider1")
provider2 = create_test_provider("provider2")
assert "azure/gpt-4o" in model_instances
assert provider_map["azure/gpt-4o"] == [provider]
assert "gpt-4o" in unique_models
# Cheaper model should win
assert should_prefer_model(
cheap_model, provider1, expensive_model, provider2, "test-alias"
def test_create_model_mappings_dedupes_with_provider_identity_not_provider_type(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Different provider instances of same type should both survive dedupe."""
provider_a_model = create_test_model(
"azure/gpt-4o", prompt_price=0.01, completion_price=0.01
)
provider_a = create_test_provider(
"azure",
"https://a.openai.azure.com/openai/v1",
db_id=1,
models=[provider_a_model],
upstream_name="azure-a",
)
provider_b = create_test_provider(
"azure",
"https://b.openai.azure.com/openai/v1",
db_id=2,
models=[],
upstream_name="azure-b",
)
# More expensive model should not win
assert not should_prefer_model(
expensive_model, provider2, cheap_model, provider1, "test-alias"
override_model = create_test_model(
"azure/gpt-4o", prompt_price=0.001, completion_price=0.001
)
override_model.canonical_slug = "azure-b-deployment"
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
return override_model
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
override_row = SimpleNamespace(id="azure/gpt-4o", upstream_provider_id=2, enabled=True)
_, provider_map, _ = create_model_mappings(
upstreams=[provider_a, provider_b],
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
disabled_model_ids=set(),
)
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")
providers_for_alias = provider_map["azure/gpt-4o"]
assert provider_a in providers_for_alias
assert provider_b in providers_for_alias
assert len(providers_for_alias) == 2

View File

@@ -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

View File

@@ -0,0 +1,104 @@
"""Tests for pricing unit normalization across model sources."""
import json
from types import TracebackType
import pytest
from routstr.core.db import ModelRow
from routstr.payment.models import _row_to_model
from routstr.upstream.generic import GenericUpstreamProvider
def test_row_to_model_normalizes_legacy_per_million_db_pricing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("routstr.payment.models.sats_usd_price", lambda: 0.00002)
row = ModelRow(
id="anthropic/claude-opus-4.5",
upstream_provider_id=1,
name="Claude Opus 4.5",
created=0,
description="test",
context_length=200000,
architecture=json.dumps(
{
"modality": "text->text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "test",
"instruct_type": None,
}
),
pricing=json.dumps(
{
"prompt": 5.0,
"completion": 25.0,
"request": 0.0,
"image": 0.0,
"web_search": 0.0,
"internal_reasoning": 0.0,
}
),
enabled=True,
)
model = _row_to_model(row)
assert model.pricing.prompt == pytest.approx(0.000005)
assert model.pricing.completion == pytest.approx(0.000025)
@pytest.mark.asyncio
async def test_generic_provider_missing_pricing_defaults_to_zero(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, object]:
return {
"data": [
{
"id": "gpt-5-chat",
"name": "gpt-5-chat",
"created": 0,
"owned_by": "openai",
"model_spec": {},
}
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
del exc_type, exc, tb
return None
async def get(
self, url: str, headers: dict[str, str] | None = None
) -> FakeResponse:
del url, headers
return FakeResponse()
monkeypatch.setattr(
"routstr.upstream.generic.httpx.AsyncClient",
lambda timeout=30.0: FakeClient(),
)
provider = GenericUpstreamProvider(base_url="https://example.test")
models = await provider.fetch_models()
assert len(models) == 1
assert models[0].pricing.prompt == 0.0
assert models[0].pricing.completion == 0.0

View File

@@ -0,0 +1,82 @@
"""Tests for Azure upstream provider request normalization."""
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream.azure import AzureUpstreamProvider
def create_test_model(model_id: str, canonical_slug: str | None = None) -> Model:
return Model(
id=model_id,
name=model_id,
created=0,
description="test",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="test",
instruct_type=None,
),
pricing=Pricing(
prompt=0.001,
completion=0.001,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
),
canonical_slug=canonical_slug,
)
def test_prepare_headers_uses_azure_api_key_header() -> None:
provider = AzureUpstreamProvider(
base_url="https://example.openai.azure.com",
api_key="azure-key",
api_version="2024-02-15-preview",
)
headers = provider.prepare_headers({"Authorization": "Bearer user-token"})
assert headers["api-key"] == "azure-key"
assert "Authorization" not in headers
assert "authorization" not in headers
def test_prepare_params_normalizes_azure_api_version() -> None:
provider = AzureUpstreamProvider(
base_url="https://example.openai.azure.com",
api_key="azure-key",
api_version="\ufeff v1 ",
)
params = provider.prepare_params("chat/completions", {})
assert params["api-version"] == "2024-02-15-preview"
def test_normalize_request_path_includes_deployment_id() -> None:
provider = AzureUpstreamProvider(
base_url="https://example.openai.azure.com/openai/v1",
api_key="azure-key",
api_version="2024-02-15-preview",
)
model = create_test_model("azure/gpt-4o", canonical_slug="deploy-gpt4o")
path = provider.normalize_request_path("v1/chat/completions", model)
assert path == "openai/deployments/deploy-gpt4o/chat/completions"
def test_get_request_base_url_strips_openai_v1_suffix() -> None:
provider = AzureUpstreamProvider(
base_url="https://example.openai.azure.com/openai/v1",
api_key="azure-key",
api_version="2024-02-15-preview",
)
model = create_test_model("azure/gpt-4o")
base_url = provider.get_request_base_url("chat/completions", model)
assert base_url == "https://example.openai.azure.com"

View File

@@ -1,11 +0,0 @@
{
"extends": [
"next",
"next/core-web-vitals",
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"plugins": ["react", "@typescript-eslint"]
}

1
ui/.gitignore vendored
View File

@@ -42,3 +42,4 @@ next-env.d.ts
# favicon conflicts
/app/favicon.ico
.pnpm-store/

5
ui/.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
pnpm-lock.yaml
.next
node_modules
out
next-env.d.ts

View File

@@ -2,12 +2,11 @@
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
import { TemporaryBalances } from '@/components/temporary-balances';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
export default function BalancesPage() {
const { displayUnit } = useCurrencyStore();
@@ -22,39 +21,26 @@ export default function BalancesPage() {
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='p-0'>
<SiteHeader />
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
</div>
{/* Global currency toggle is now in SiteHeader */}
</div>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-6'>
<PageHeader
title='Balances'
description='Monitor and manage wallet balances across cashu mints and temporary stores.'
/>
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='col-span-full'>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
</div>
<div className='grid gap-6'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
</SidebarInset>
</SidebarProvider>
</div>
</AppPageShell>
);
}

View File

@@ -1,13 +1,133 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@custom-variant dark (&:is(.dark *, .red *));
:root {
--radius: 0.625rem;
--font-geist-sans:
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
--font-geist-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
.red {
--background: oklch(0.14 0.03 24);
--foreground: oklch(0.9 0.05 28);
--card: oklch(0.17 0.04 24);
--card-foreground: oklch(0.9 0.05 28);
--popover: oklch(0.17 0.04 24);
--popover-foreground: oklch(0.9 0.05 28);
--primary: oklch(0.78 0.14 25);
--primary-foreground: oklch(0.14 0.03 24);
--secondary: oklch(0.22 0.05 24);
--secondary-foreground: oklch(0.9 0.05 28);
--muted: oklch(0.22 0.05 24);
--muted-foreground: oklch(0.72 0.04 26);
--accent: oklch(0.25 0.08 25);
--accent-foreground: oklch(0.92 0.05 28);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.72 0.11 25 / 24%);
--input: oklch(0.75 0.12 25 / 28%);
--ring: oklch(0.62 0.12 24);
}
html.red {
color-scheme: dark;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: Geist, sans-serif;
--font-mono: Geist Mono, monospace;
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-border: var(--border);
--color-card: var(--card);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-ring: var(--ring);
--color-input: var(--input);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -21,194 +141,40 @@
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-serif: Georgia, serif;
--radius: 0.5rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.5rem;
--background: oklch(0.99 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.94 0 0);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.44 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.63 0.19 23.03);
--border: oklch(0.92 0 0);
--input: oklch(0.94 0 0);
--ring: oklch(0 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.55 0.22 264.53);
--chart-3: oklch(0.72 0 0);
--chart-4: oklch(0.92 0 0);
--chart-5: oklch(0.56 0 0);
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.94 0 0);
--sidebar-ring: oklch(0 0 0);
--destructive-foreground: oklch(1 0 0);
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.18;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
--tracking-normal: 0em;
body {
font-family: var(--font-sans);
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.14 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.18 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.25 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.23 0 0);
--muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.69 0.2 23.91);
--border: oklch(0.26 0 0);
--input: oklch(0.32 0 0);
--ring: oklch(0.72 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.58 0.21 260.84);
--chart-3: oklch(0.56 0 0);
--chart-4: oklch(0.44 0 0);
--chart-5: oklch(0.92 0 0);
--sidebar: oklch(0.18 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.32 0 0);
--sidebar-ring: oklch(0.72 0 0);
--destructive-foreground: oklch(0 0 0);
--radius: 0.5rem;
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.18;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
html,
body {
max-width: 100%;
}
@layer utilities {
.pb-mobile-nav {
padding-bottom: calc(5.75rem + env(safe-area-inset-bottom));
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}
/* Custom animations */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
button,
[type='button'],
[type='submit'],
[type='reset'],
[role='button'] {
cursor: pointer;
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}

View File

@@ -1,23 +1,10 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
import './globals.css';
import { Providers } from './providers';
import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
preload: false,
display: 'swap',
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
preload: false,
display: 'swap',
});
export const metadata: Metadata = {
title: 'Routstr',
description: 'Routstr model management',
@@ -34,7 +21,7 @@ export default function RootLayout({
return (
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}
>
<SuppressHydrationWarning>
<Providers>{children}</Providers>

View File

@@ -5,16 +5,10 @@ import type { ChangeEvent, FormEvent, ReactElement } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { adminLogin } from '@/lib/api/services/auth';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { toast } from 'sonner';
import { AuthPageShell } from '@/components/auth-page-shell';
export default function AdminLoginPage(): ReactElement {
const router = useRouter();
@@ -78,51 +72,42 @@ export default function AdminLoginPage(): ReactElement {
};
return (
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
<CardHeader className='space-y-1'>
<CardTitle className='text-center text-2xl font-bold'>
Admin Login
</CardTitle>
<CardDescription className='text-center'>
Enter your admin password to access the dashboard
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
{allowCustomBaseUrl && (
<div className='space-y-2'>
<Input
type='text'
placeholder='API URL (https://api.example.com)'
value={baseUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setBaseUrl(event.target.value)
}
disabled={isLoading}
required
/>
</div>
)}
<div className='space-y-2'>
<Input
type='password'
placeholder='Admin Password'
value={password}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setPassword(event.target.value)
}
disabled={isLoading}
autoFocus
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
</CardContent>
</Card>
</div>
<AuthPageShell
title='Admin Login'
description='Enter your admin password to access the dashboard.'
>
<form onSubmit={handleSubmit} className='space-y-4'>
{allowCustomBaseUrl && (
<div className='space-y-2'>
<Input
type='text'
placeholder='API URL (https://api.example.com)'
value={baseUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setBaseUrl(event.target.value)
}
disabled={isLoading}
required
/>
</div>
)}
<div className='space-y-2'>
<Input
type='password'
placeholder='Admin Password'
value={password}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setPassword(event.target.value)
}
disabled={isLoading}
autoFocus
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
</AuthPageShell>
);
}

View File

@@ -10,18 +10,8 @@ import {
} from '@/components/ui/dialog';
import { Copy, Check } from 'lucide-react';
import { useState } from 'react';
interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname: string;
lineno: number;
version: string;
request_id: string;
[key: string]: string | number | object | undefined;
}
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
import type { LogEntry } from './types';
interface LogDetailsDialogProps {
log: LogEntry | null;
@@ -29,24 +19,6 @@ interface LogDetailsDialogProps {
onClose: () => void;
}
const getLevelColor = (level: string): string => {
switch (level.toUpperCase()) {
case 'TRACE':
case 'DEBUG':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'INFO':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'ERROR':
return 'bg-red-100 text-red-800 border-red-200';
case 'CRITICAL':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function LogDetailsDialog({
log,
isOpen,
@@ -79,10 +51,13 @@ export function LogDetailsDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
<DialogContent className='max-h-[92svh] w-full max-w-none overflow-hidden md:max-w-4xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Badge variant='outline' className={getLevelColor(log.levelname)}>
<Badge
variant={getLogLevelBadgeVariant(log.levelname)}
className='uppercase'
>
{log.levelname}
</Badge>
<span>Log Entry Details</span>
@@ -92,7 +67,7 @@ export function LogDetailsDialog({
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
<ScrollArea className='h-[70svh] w-full overflow-x-auto sm:h-[75vh]'>
<div className='space-y-6'>
<div>
<h4 className='mb-2 text-sm font-medium'>Message</h4>

View File

@@ -1,41 +1,13 @@
import { Badge } from '@/components/ui/badge';
import { Eye } from 'lucide-react';
interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname: string;
lineno: number;
version: string;
request_id: string;
[key: string]: string | number | object | undefined;
}
import { ChevronRight } from 'lucide-react';
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
import type { LogEntry } from './types';
interface LogEntryCardProps {
entry: LogEntry;
onClick: (entry: LogEntry) => void;
}
const getLevelColor = (level: string): string => {
switch (level.toUpperCase()) {
case 'TRACE':
case 'DEBUG':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'INFO':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'ERROR':
return 'bg-red-100 text-red-800 border-red-200';
case 'CRITICAL':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
const extraFields = Object.keys(entry).filter(
(key) =>
@@ -50,73 +22,58 @@ export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
'request_id',
].includes(key)
);
const hasRequestId =
Boolean(entry.request_id) && entry.request_id !== 'no-request-id';
const shortPath = entry.pathname.split('/').pop() || entry.pathname;
return (
<div
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
className='bg-card hover:bg-accent/35 group mb-2 cursor-pointer rounded-lg border p-2.5 transition-colors duration-150 sm:p-3'
onClick={() => onClick(entry)}
>
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<Badge variant='outline' className={getLevelColor(entry.levelname)}>
{entry.levelname}
</Badge>
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
{entry.asctime}
</span>
<Badge variant='secondary' className='truncate text-xs'>
{entry.name}
</Badge>
</div>
<div className='flex min-w-0 items-center gap-2'>
<div className='text-muted-foreground truncate text-xs'>
{entry.pathname}:{entry.lineno}
</div>
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
</div>
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
{entry.message}
</div>
{entry.request_id && entry.request_id !== 'no-request-id' && (
<div className='mb-2 min-w-0'>
<div className='inline-block max-w-full'>
<Badge variant='outline' className='text-xs'>
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
Request ID: {entry.request_id}
</span>
<div className='flex min-w-0 items-start justify-between gap-2'>
<div className='min-w-0 flex-1 space-y-1.5'>
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
<Badge
variant={getLogLevelBadgeVariant(entry.levelname)}
className='h-5 px-1.5 text-[10px] uppercase'
>
{entry.levelname}
</Badge>
<span className='text-muted-foreground text-[11px]'>
{entry.asctime}
</span>
<Badge
variant='secondary'
className='h-5 max-w-[11rem] truncate px-1.5 text-[10px]'
>
{entry.name}
</Badge>
</div>
</div>
)}
{extraFields.length > 0 && (
<div className='mt-3 min-w-0 border-t pt-3'>
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
<div className='grid grid-cols-1 gap-2'>
{extraFields.slice(0, 4).map((key) => (
<div
key={key}
className='min-w-0 overflow-hidden text-xs break-words'
>
<span className='font-medium break-all'>{key}:</span>{' '}
<span className='text-muted-foreground break-all'>
{typeof entry[key] === 'object'
? JSON.stringify(entry[key])
: String(entry[key])}
</span>
</div>
))}
{extraFields.length > 4 && (
<div className='text-muted-foreground text-xs'>
...and {extraFields.length - 4} more fields
</div>
)}
<p className='line-clamp-1 font-mono text-xs break-words sm:text-sm'>
{entry.message}
</p>
<div className='text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-[11px]'>
{hasRequestId ? (
<span className='inline-block max-w-[14rem] truncate rounded border px-1.5 py-0.5 font-mono text-[10px] sm:max-w-[20rem]'>
{entry.request_id}
</span>
) : null}
<span className='truncate'>
{shortPath}:{entry.lineno}
</span>
{extraFields.length > 0 ? (
<span>{extraFields.length} extra</span>
) : null}
</div>
</div>
)}
<div className='pt-0.5'>
<ChevronRight className='text-muted-foreground h-4 w-4 opacity-50 transition-opacity group-hover:opacity-90' />
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,11 @@
import {
useEffect,
useState,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import { format } from 'date-fns';
import { CalendarIcon, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -21,20 +29,8 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { MultiSelectCommandFilter } from './multi-select-command-filter';
interface LogFiltersProps {
selectedDate: string;
@@ -97,32 +93,12 @@ const ENDPOINT_OPTIONS = [
'/embeddings/models',
];
interface FilterBadgeProps {
value: string;
onRemove: (value: string) => void;
}
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<button
type='button'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(value);
}}
className='hover:bg-muted-foreground/20 rounded-full'
>
<X className='h-3 w-3' />
</button>
</Badge>
);
}
const STATUS_4XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
code.startsWith('4')
);
const STATUS_5XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
code.startsWith('5')
);
export function LogFilters({
selectedDate,
@@ -151,7 +127,7 @@ export function LogFilters({
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
const [date, setDate] = useState<Date | undefined>(
selectedDate && selectedDate !== 'all'
? new Date(selectedDate + 'T00:00:00')
? new Date(`${selectedDate}T00:00:00`)
: undefined
);
@@ -162,6 +138,7 @@ export function LogFilters({
useEffect(() => {
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
setIsCustom(!currentIsPreset);
if (!currentIsPreset) {
setCustomLimit(limit.toString());
}
@@ -170,88 +147,79 @@ export function LogFilters({
useEffect(() => {
if (selectedDate === 'all' || !selectedDate) {
setDate(undefined);
} else {
const d = new Date(selectedDate + 'T00:00:00');
setDate(isNaN(d.getTime()) ? undefined : d);
return;
}
const parsedDate = new Date(`${selectedDate}T00:00:00`);
setDate(Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate);
}, [selectedDate]);
const handleLimitChange = (value: string) => {
if (value === 'custom') {
setIsCustom(true);
setCustomLimit(limit.toString());
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(Number(value));
return;
}
setIsCustom(false);
setCustomLimit('');
onLimitChange(Number(value));
};
const handleCustomLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setCustomLimit(value);
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
setCustomLimit(event.target.value);
};
const handleCustomLimitApply = () => {
const numValue = parseInt(customLimit);
if (!isNaN(numValue) && numValue > 0) {
onLimitChange(numValue);
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(100);
const numericValue = Number.parseInt(customLimit, 10);
if (!Number.isNaN(numericValue) && numericValue > 0) {
onLimitChange(numericValue);
return;
}
setIsCustom(false);
setCustomLimit('');
onLimitChange(100);
};
const handleCustomLimitKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === 'Enter') {
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleCustomLimitApply();
}
};
const handleDateSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate);
if (selectedDate) {
onDateChange(format(selectedDate, 'yyyy-MM-dd'));
} else {
onDateChange('all');
}
};
const handleDateSelect = (nextDate: Date | undefined) => {
setDate(nextDate);
const toggleSelection = (
current: string[],
value: string,
onChange: (val: string[]) => void
) => {
if (current.includes(value)) {
onChange(current.filter((v) => v !== value));
} else {
onChange([...current, value]);
if (nextDate) {
onDateChange(format(nextDate, 'yyyy-MM-dd'));
return;
}
onDateChange('all');
};
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
const newSelection = new Set([...selectedStatusCodes]);
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
const rangeCodes = range === '4xx' ? STATUS_4XX_CODES : STATUS_5XX_CODES;
const nextSelection = new Set(selectedStatusCodes);
const allSelected = rangeCodes.every((code) =>
selectedStatusCodes.includes(code)
);
if (allIncluded) {
codes.forEach((c) => newSelection.delete(c));
if (allSelected) {
rangeCodes.forEach((code) => nextSelection.delete(code));
} else {
codes.forEach((c) => newSelection.add(c));
rangeCodes.forEach((code) => nextSelection.add(code));
}
onStatusCodesChange(Array.from(newSelection));
onStatusCodesChange(Array.from(nextSelection));
};
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Filter className='h-5 w-5' />
Filters
</CardTitle>
<CardTitle>Filters</CardTitle>
<CardDescription>
Filter logs by date, level, request ID, text search, status code,
method, endpoint and limit
@@ -314,336 +282,62 @@ export function LogFilters({
</Select>
</div>
<div className='space-y-2'>
<Label>Status Codes</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedStatusCodes.length > 0 ? (
selectedStatusCodes.map((code) => (
<FilterBadge
key={code}
value={code}
onRemove={(val) =>
toggleSelection(
selectedStatusCodes,
val,
onStatusCodesChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All codes</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add status code...'
value={statusSearch}
onValueChange={setStatusSearch}
/>
<CommandList>
{selectedStatusCodes.length > 0 && (
<CommandGroup heading='Selected'>
{selectedStatusCodes.map((code) => (
<CommandItem
key={`selected-${code}`}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
)}
{statusSearch &&
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
!selectedStatusCodes.includes(statusSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
if (/^\d+$/.test(statusSearch)) {
toggleSelection(
selectedStatusCodes,
statusSearch,
onStatusCodesChange
);
setStatusSearch('');
}
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{statusSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Quick Filters'>
<CommandItem
onSelect={() => handleQuickStatusCode('4xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('4')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
4xx Errors
</CommandItem>
<CommandItem
onSelect={() => handleQuickStatusCode('5xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('5')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
5xx Errors
</CommandItem>
</CommandGroup>
<CommandGroup heading='Common Codes'>
{STATUS_CODE_OPTIONS.filter(
(code) => !selectedStatusCodes.includes(code)
).map((code) => (
<CommandItem
key={code}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='Status Codes'
emptyLabel='All codes'
selectedValues={selectedStatusCodes}
onSelectedValuesChange={onStatusCodesChange}
options={STATUS_CODE_OPTIONS}
searchValue={statusSearch}
onSearchValueChange={setStatusSearch}
searchPlaceholder='Search or add status code...'
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Common Codes'
canAddCustom={(value) => /^\d+$/.test(value)}
quickFilters={[
{
label: '4xx Errors',
checked: STATUS_4XX_CODES.every((code) =>
selectedStatusCodes.includes(code)
),
onSelect: () => handleQuickStatusCode('4xx'),
},
{
label: '5xx Errors',
checked: STATUS_5XX_CODES.every((code) =>
selectedStatusCodes.includes(code)
),
onSelect: () => handleQuickStatusCode('5xx'),
},
]}
/>
<div className='space-y-2'>
<Label>HTTP Methods</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedMethods.length > 0 ? (
selectedMethods.map((method) => (
<FilterBadge
key={method}
value={method}
onRemove={(val) =>
toggleSelection(
selectedMethods,
val,
onMethodsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All methods</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add method...'
value={methodSearch}
onValueChange={setMethodSearch}
/>
<CommandList>
{selectedMethods.length > 0 && (
<CommandGroup heading='Selected'>
{selectedMethods.map((method) => (
<CommandItem
key={`selected-${method}`}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
)}
{methodSearch &&
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
!selectedMethods.includes(methodSearch.toUpperCase()) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedMethods,
methodSearch.toUpperCase(),
onMethodsChange
);
setMethodSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{methodSearch.toUpperCase()}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{METHOD_OPTIONS.filter(
(method) => !selectedMethods.includes(method)
).map((method) => (
<CommandItem
key={method}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='HTTP Methods'
emptyLabel='All methods'
selectedValues={selectedMethods}
onSelectedValuesChange={onMethodsChange}
options={METHOD_OPTIONS}
searchValue={methodSearch}
onSearchValueChange={setMethodSearch}
searchPlaceholder='Search or add method...'
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Methods'
normalizeCustomValue={(value) => value.toUpperCase()}
/>
<div className='space-y-2'>
<Label>Endpoints</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1 overflow-hidden'>
{selectedEndpoints.length > 0 ? (
selectedEndpoints.map((endpoint) => (
<FilterBadge
key={endpoint}
value={endpoint}
onRemove={(val) =>
toggleSelection(
selectedEndpoints,
val,
onEndpointsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>
All endpoints
</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-80 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add endpoint pattern...'
value={endpointSearch}
onValueChange={setEndpointSearch}
/>
<CommandList>
{selectedEndpoints.length > 0 && (
<CommandGroup heading='Selected'>
{selectedEndpoints.map((endpoint) => (
<CommandItem
key={`selected-${endpoint}`}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
)}
{endpointSearch &&
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
!selectedEndpoints.includes(endpointSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedEndpoints,
endpointSearch,
onEndpointsChange
);
setEndpointSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{endpointSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Common Endpoints'>
{ENDPOINT_OPTIONS.filter(
(endpoint) => !selectedEndpoints.includes(endpoint)
).map((endpoint) => (
<CommandItem
key={endpoint}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='Endpoints'
emptyLabel='All endpoints'
selectedValues={selectedEndpoints}
onSelectedValuesChange={onEndpointsChange}
options={ENDPOINT_OPTIONS}
searchValue={endpointSearch}
onSearchValueChange={setEndpointSearch}
searchPlaceholder='Search or add endpoint pattern...'
popoverClassName='w-[min(20rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Common Endpoints'
/>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
@@ -652,7 +346,7 @@ export function LogFilters({
type='text'
placeholder='Search by request ID'
value={requestId}
onChange={(e) => onRequestIdChange(e.target.value)}
onChange={(event) => onRequestIdChange(event.target.value)}
/>
</div>
@@ -668,14 +362,14 @@ export function LogFilters({
type='text'
placeholder='Search in message and name'
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onChange={(event) => onSearchTextChange(event.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='limit'>Limit</Label>
{isCustom ? (
<div className='flex gap-2'>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='limit'
type='number'
@@ -686,7 +380,7 @@ export function LogFilters({
onKeyDown={handleCustomLimitKeyDown}
onBlur={handleCustomLimitApply}
autoFocus
className='flex-1'
className='flex-1 sm:flex-auto'
/>
<Button
type='button'
@@ -695,6 +389,7 @@ export function LogFilters({
onClick={() => {
setIsCustom(false);
setCustomLimit('');
if (!isPreset) {
onLimitChange(100);
}
@@ -712,12 +407,11 @@ export function LogFilters({
<SelectValue placeholder='Select limit' />
</SelectTrigger>
<SelectContent>
<SelectItem value='25'>25</SelectItem>
<SelectItem value='50'>50</SelectItem>
<SelectItem value='100'>100</SelectItem>
<SelectItem value='200'>200</SelectItem>
<SelectItem value='500'>500</SelectItem>
<SelectItem value='1000'>1000</SelectItem>
{PRESET_LIMITS.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
<SelectItem value='custom'>Custom...</SelectItem>
</SelectContent>
</Select>
@@ -727,8 +421,7 @@ export function LogFilters({
)}
</div>
<div className='space-y-2'>
<Label>&nbsp;</Label>
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
<Button
onClick={onClearFilters}
variant='outline'

View File

@@ -0,0 +1,182 @@
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X } from 'lucide-react';
interface QuickFilterOption {
label: string;
checked: boolean;
onSelect: () => void;
}
interface MultiSelectCommandFilterProps {
label: string;
emptyLabel: string;
selectedValues: string[];
onSelectedValuesChange: (values: string[]) => void;
options: string[];
searchValue: string;
onSearchValueChange: (value: string) => void;
searchPlaceholder: string;
popoverClassName?: string;
selectedGroupLabel?: string;
customGroupLabel?: string;
quickGroupLabel?: string;
optionsGroupLabel?: string;
quickFilters?: QuickFilterOption[];
normalizeCustomValue?: (value: string) => string;
canAddCustom?: (value: string) => boolean;
}
function FilterBadge({ value }: { value: string }) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<X className='h-3 w-3 opacity-70' aria-hidden='true' />
</Badge>
);
}
export function MultiSelectCommandFilter({
label,
emptyLabel,
selectedValues,
onSelectedValuesChange,
options,
searchValue,
onSearchValueChange,
searchPlaceholder,
popoverClassName = 'w-64 p-0',
selectedGroupLabel = 'Selected',
customGroupLabel = 'Custom',
quickGroupLabel = 'Quick Filters',
optionsGroupLabel = 'Options',
quickFilters = [],
normalizeCustomValue,
canAddCustom,
}: MultiSelectCommandFilterProps) {
const toggleSelection = (value: string) => {
if (selectedValues.includes(value)) {
onSelectedValuesChange(selectedValues.filter((item) => item !== value));
return;
}
onSelectedValuesChange([...selectedValues, value]);
};
const normalizedSearch = normalizeCustomValue
? normalizeCustomValue(searchValue)
: searchValue;
const canShowCustomAction =
normalizedSearch.length > 0 &&
!options.includes(normalizedSearch) &&
!selectedValues.includes(normalizedSearch) &&
(canAddCustom ? canAddCustom(normalizedSearch) : true);
return (
<div className='space-y-2'>
<Label>{label}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1 overflow-hidden'>
{selectedValues.length > 0 ? (
selectedValues.map((value) => (
<FilterBadge key={value} value={value} />
))
) : (
<span className='text-muted-foreground'>{emptyLabel}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className={popoverClassName} align='start'>
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={onSearchValueChange}
/>
<CommandList>
{selectedValues.length > 0 && (
<CommandGroup heading={selectedGroupLabel}>
{selectedValues.map((value) => (
<CommandItem
key={`selected-${value}`}
onSelect={() => toggleSelection(value)}
>
<Checkbox checked={true} className='mr-2' />
{value}
</CommandItem>
))}
</CommandGroup>
)}
{canShowCustomAction && (
<CommandGroup heading={customGroupLabel}>
<CommandItem
onSelect={() => {
toggleSelection(normalizedSearch);
onSearchValueChange('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{normalizedSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
{quickFilters.length > 0 && (
<CommandGroup heading={quickGroupLabel}>
{quickFilters.map((filter) => (
<CommandItem key={filter.label} onSelect={filter.onSelect}>
<Checkbox checked={filter.checked} className='mr-2' />
{filter.label}
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup heading={optionsGroupLabel}>
{options
.filter((option) => !selectedValues.includes(option))
.map((option) => (
<CommandItem
key={option}
onSelect={() => toggleSelection(option)}
>
<Checkbox checked={false} className='mr-2' />
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -2,8 +2,6 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -14,9 +12,18 @@ import {
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
import { FileText, RefreshCw } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { LogEntry, LogsResponse } from './types';
import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
@@ -135,126 +142,125 @@ export default function LogsPage() {
setIsDialogOpen(true);
};
const hasActiveFilters =
selectedDate !== 'all' ||
selectedLevel !== 'all' ||
Boolean(requestId) ||
Boolean(searchText) ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0;
const activeFilterDescription = [
selectedDate !== 'all' ? `date ${selectedDate}` : null,
selectedLevel !== 'all' ? `level ${selectedLevel}` : null,
requestId ? `request ID ${requestId}` : null,
searchText ? `text "${searchText}"` : null,
selectedStatusCodes.length > 0
? `status ${selectedStatusCodes.join(', ')}`
: null,
selectedMethods.length > 0 ? `method ${selectedMethods.join(', ')}` : null,
selectedEndpoints.length > 0
? `endpoint ${selectedEndpoints.join(', ')}`
: null,
]
.filter(Boolean)
.join(' • ');
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='overflow-x-hidden p-0'>
<SiteHeader />
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
System Logs
</h1>
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
View and filter application logs
</p>
</div>
<Button
onClick={() => refetchLogs()}
variant='outline'
size='sm'
className='self-start'
>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
<div className='space-y-6'>
<PageHeader
title='System Logs'
description='View and filter application logs.'
actions={
<Button onClick={() => refetchLogs()} variant='outline' size='sm'>
<RefreshCw className='mr-2 h-4 w-4' />
Refresh
</Button>
</div>
}
/>
<LogFilters
selectedDate={selectedDate}
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
<LogFilters
selectedDate={selectedDate}
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
<Card>
<CardHeader>
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-lg sm:text-xl'>Log Entries</span>
{logsData && (
<Badge variant='secondary' className='text-xs sm:text-sm'>
{logsData.logs.length} entries
</Badge>
)}
</CardTitle>
{(selectedDate !== 'all' ||
selectedLevel !== 'all' ||
requestId ||
searchText ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0) && (
<CardDescription className='text-xs sm:text-sm'>
Showing logs
{selectedDate !== 'all' && ` for ${selectedDate}`}
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
{requestId && ` with request ID ${requestId}`}
{searchText && ` matching "${searchText}"`}
{selectedStatusCodes.length > 0 &&
` with status ${selectedStatusCodes.join(', ')}`}
{selectedMethods.length > 0 &&
` with method ${selectedMethods.join(', ')}`}
{selectedEndpoints.length > 0 &&
` with endpoint ${selectedEndpoints.join(', ')}`}
</CardDescription>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>Log Entries</CardTitle>
{logsData && (
<Badge variant='secondary'>
{logsData.logs.length} entries
</Badge>
)}
</CardHeader>
<CardContent className='overflow-hidden p-3 sm:p-6'>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<RefreshCw className='h-6 w-6 animate-spin' />
<span className='ml-2 text-sm sm:text-base'>
Loading logs...
</span>
</div>
{hasActiveFilters && (
<CardDescription>
Showing logs filtered by {activeFilterDescription}
</CardDescription>
)}
</CardHeader>
<CardContent className='overflow-hidden'>
{isLoading ? (
<div className='space-y-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={`logs-loading-${index}`}
className='h-16 w-full rounded-lg'
/>
))}
</div>
) : logsData?.logs && logsData.logs.length > 0 ? (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
<div className='space-y-2 pr-3'>
{logsData.logs.map((entry, index) => (
<LogEntryCard
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
entry={entry}
onClick={handleLogClick}
/>
))}
</div>
) : logsData?.logs && logsData.logs.length > 0 ? (
<>
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
<div className='space-y-2 pr-3'>
{logsData.logs.map((entry, index) => (
<LogEntryCard
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
entry={entry}
onClick={handleLogClick}
/>
))}
</div>
</ScrollArea>
</>
) : (
<div className='text-muted-foreground py-8 text-center'>
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
<p className='text-sm sm:text-base'>No log entries found</p>
<p className='text-xs sm:text-sm'>
Try adjusting your filters or check back later
</p>
</div>
)}
</CardContent>
</Card>
</ScrollArea>
) : (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<FileText className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No log entries found</EmptyTitle>
<EmptyDescription>
Try adjusting your filters or check back later.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CardContent>
</Card>
<LogDetailsDialog
log={selectedLog}
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</div>
</SidebarInset>
</SidebarProvider>
<LogDetailsDialog
log={selectedLog}
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</div>
</AppPageShell>
);
}

View File

@@ -1,329 +0,0 @@
'use client';
import { ModelSelector } from '@/components/ModelSelector';
import { ModelTester } from '@/components/ModelTester';
import { ApiEndpointTester } from '@/components/ApiEndpointTester';
import { ModelSearchFilter } from '@/components/ModelSearchFilter';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { AdminService } from '@/lib/api/services/admin';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle, Users, Globe } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { useMemo, useState } from 'react';
import type { Model } from '@/lib/api/schemas/models';
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
export default function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useQuery({
queryKey: ['admin-models-with-providers'],
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo(() => {
return groupAndSortModelsByProvider(models);
}, [models]);
const groupDataMap = useMemo(() => {
return new Map(groups.map((group) => [group.provider, group]));
}, [groups]);
const providerInfo = useMemo(() => {
const allProviders = new Set([
...Object.keys(groupedModels),
...groups.map((g) => g.provider),
]);
console.log(allProviders);
return Array.from(allProviders).map((provider) => {
const providerModels = groupedModels[provider] || [];
const groupData = groupDataMap.get(provider);
const activeModels = providerModels.filter(
(m) => m.isEnabled && !m.soft_deleted
).length;
const totalModels = providerModels.length;
return {
provider,
activeModels,
totalModels,
groupData,
hasGroupUrl: !!groupData?.group_url,
hasGroupApiKey: !!groupData?.group_api_key,
};
});
}, [groupedModels, groupDataMap, groups]);
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<h1 className='text-2xl font-bold tracking-tight'>
Model Management & API Testing
</h1>
</div>
<Tabs defaultValue='manage' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
</TabsList>
<TabsContent value='manage' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Manage your AI models organized by provider groups. Configure
API keys, and organize models by provider groups.
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[60px] w-full' />
<Skeleton className='h-[400px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models. Please try refreshing the page.
</AlertDescription>
</Alert>
) : (
<Tabs defaultValue='all' className='w-full'>
<div className='space-y-4'>
{/* Provider Tabs Navigation */}
<div className='overflow-x-auto rounded-lg border p-1'>
<TabsList className='grid w-full max-w-full min-w-max auto-cols-fr grid-flow-col gap-1 sm:gap-2'>
<TabsTrigger
value='all'
className='flex items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
>
<Globe className='h-3 w-3 sm:h-4 sm:w-4' />
<span className='hidden sm:inline'>All Models</span>
<span className='sm:hidden'>All</span>
<Badge variant='secondary' className='ml-1 text-xs'>
{models.length}
</Badge>
</TabsTrigger>
{providerInfo.map(
({ provider, activeModels, totalModels }) => (
<TabsTrigger
key={provider}
value={provider}
className='flex min-w-fit items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
>
<Users className='h-3 w-3 sm:h-4 sm:w-4' />
<span className='max-w-20 truncate sm:max-w-none'>
{provider}
</span>
<div className='flex items-center gap-1'>
<Badge
variant='secondary'
className='ml-1 text-xs'
>
{activeModels}/{totalModels}
</Badge>
</div>
</TabsTrigger>
)
)}
</TabsList>
</div>
{/* All Models Tab */}
<TabsContent value='all'>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Overview of all models across all provider groups.
</div>
<ModelSearchFilter
models={models}
onFilteredModelsChange={setFilteredModels}
/>
<ModelSelector
filteredModels={filteredModels}
showDeleteAllButton={true}
/>
</div>
</TabsContent>
{providerInfo.map(
({ provider, totalModels, groupData }) => {
const providerModels = groupedModels[provider] || [];
return (
<TabsContent key={provider} value={provider}>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h3 className='flex items-center gap-2 text-lg font-semibold'>
<Users className='h-5 w-5' />
{provider}
</h3>
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
{providerModels.filter(
(m) => m.soft_deleted
).length > 0 && (
<span className='text-orange-600'>
{
providerModels.filter(
(m) => m.soft_deleted
).length
}{' '}
disabled
</span>
)}
{groupData?.group_url && (
<span className='flex items-center gap-1'>
<Globe className='h-3 w-3' />
{groupData.group_url}
</span>
)}
{totalModels === 0 && (
<span className='text-muted-foreground'>
No models configured
</span>
)}
</div>
</div>
</div>
{totalModels === 0 && (
<Alert className='mb-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='space-y-2'>
<p className='font-medium'>
No models found for this provider
</p>
<div className='space-y-1 text-sm'>
<p className='font-medium'>
Common issues:
</p>
<ul className='ml-2 list-inside list-disc space-y-1'>
<li>
<strong>API credentials:</strong>{' '}
Check if the API key is correct
and has the right permissions
</li>
<li>
<strong>Base URL:</strong> Verify
the base URL is correct for your
provider
</li>
<li>
<strong>Network access:</strong>{' '}
Ensure the server can reach the
provider&apos;s API endpoint
</li>
<li>
<strong>Provider status:</strong>{' '}
The upstream provider might be
temporarily unavailable
</li>
</ul>
{groupData?.group_url && (
<p className='text-muted-foreground mt-2 text-xs'>
Current endpoint:{' '}
<code className='bg-muted rounded px-1'>
{groupData.group_url}
</code>
</p>
)}
</div>
</div>
</AlertDescription>
</Alert>
)}
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
</div>
</TabsContent>
);
}
)}
</div>
</Tabs>
)}
</TabsContent>
<TabsContent value='test-basic' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Test model credentials and connectivity with basic chat
completion requests through the secure proxy (resolves CORS
and Docker network issues). Models can be tested even without
API keys configured (useful for free models or when
authentication is handled elsewhere).
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[200px] w-full' />
<Skeleton className='h-[100px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for testing. Please try refreshing
the page.
</AlertDescription>
</Alert>
) : (
<ModelTester models={models} />
)}
</TabsContent>
<TabsContent value='test-api' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Comprehensive testing of all OpenAI API endpoints including
chat completions, embeddings, image generation, audio
synthesis, and model listing through the secure proxy
(resolves CORS and Docker network issues). Models can be
tested with or without API keys configured.
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[300px] w-full' />
<Skeleton className='h-[200px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for API testing. Please try
refreshing the page.
</AlertDescription>
</Alert>
) : (
<ApiEndpointTester models={models} />
)}
</TabsContent>
</Tabs>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

253
ui/app/models/page.tsx Normal file
View File

@@ -0,0 +1,253 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle } from 'lucide-react';
import type { Model } from '@/lib/api/schemas/models';
import { AdminService } from '@/lib/api/services/admin';
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { ModelSelector } from '@/components/model-selector';
import { ModelTester } from '@/components/model-tester';
import { ApiEndpointTester } from '@/components/api-endpoint-tester';
import { ModelSearchFilter } from '@/components/model-search-filter';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export default function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
undefined
);
const [selectedProviderScope, setSelectedProviderScope] =
useState<string>('all');
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useQuery({
queryKey: ['admin-models-with-providers'],
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo(
() => groupAndSortModelsByProvider(models),
[models]
);
const groupDataMap = useMemo(
() => new Map(groups.map((group) => [group.provider, group])),
[groups]
);
const providerInfo = useMemo(() => {
const allProviders = new Set([
...Object.keys(groupedModels),
...groups.map((group) => group.provider),
]);
return Array.from(allProviders)
.map((provider) => {
const providerModels = groupedModels[provider] || [];
const groupData = groupDataMap.get(provider);
return {
provider,
totalModels: providerModels.length,
disabledModels: providerModels.filter((model) => model.soft_deleted)
.length,
groupData,
};
})
.sort((a, b) => a.provider.localeCompare(b.provider));
}, [groupDataMap, groupedModels, groups]);
const activeProviderScope = useMemo(() => {
if (selectedProviderScope === 'all') {
return 'all';
}
const providerExists = providerInfo.some(
(provider) => provider.provider === selectedProviderScope
);
return providerExists ? selectedProviderScope : 'all';
}, [providerInfo, selectedProviderScope]);
const selectedProviderGroup =
activeProviderScope === 'all'
? undefined
: groupDataMap.get(activeProviderScope);
const scopedModels = useMemo(() => {
if (activeProviderScope === 'all') {
return models;
}
return models.filter((model) => model.provider === activeProviderScope);
}, [activeProviderScope, models]);
return (
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-3 sm:space-y-4'>
<PageHeader
title='Model Management'
description='Manage provider model catalogs and validate endpoints from one place.'
/>
<Tabs defaultValue='manage' className='w-full gap-3 sm:gap-4'>
<TabsList
variant='line'
className='w-full snap-x snap-mandatory justify-start gap-0.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
>
<TabsTrigger
value='manage'
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
>
Manage Models
</TabsTrigger>
<TabsTrigger
value='test-basic'
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
>
Basic Testing
</TabsTrigger>
<TabsTrigger
value='test-api'
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
>
API Endpoints
</TabsTrigger>
</TabsList>
<TabsContent value='manage' className='mt-0'>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-16 w-full' />
<Skeleton className='h-[420px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models. Please try refreshing the page.
</AlertDescription>
</Alert>
) : (
<div className='space-y-3 sm:space-y-4'>
<div className='flex flex-col gap-2 sm:gap-2.5 md:flex-row md:items-center'>
<Select
value={activeProviderScope}
onValueChange={(value) => {
setSelectedProviderScope(value);
setFilteredModels(undefined);
}}
>
<SelectTrigger className='h-8 w-full md:w-[220px]'>
<SelectValue placeholder='Provider scope' />
</SelectTrigger>
<SelectContent align='start'>
<SelectItem value='all'>
All providers ({models.length})
</SelectItem>
{providerInfo.map(({ provider, totalModels }) => (
<SelectItem key={provider} value={provider}>
{provider} ({totalModels})
</SelectItem>
))}
</SelectContent>
</Select>
<ModelSearchFilter
models={scopedModels}
onFilteredModelsChange={setFilteredModels}
className='w-full min-w-0 flex-1'
/>
</div>
<ModelSelector
filterProvider={
activeProviderScope === 'all'
? undefined
: activeProviderScope
}
groupData={selectedProviderGroup}
filteredModels={filteredModels}
showDeleteAllButton={activeProviderScope === 'all'}
/>
</div>
)}
</TabsContent>
<TabsContent value='test-basic' className='mt-0 space-y-3'>
<div className='space-y-1'>
<h3 className='text-base font-semibold'>
Basic Credential Testing
</h3>
<p className='text-muted-foreground text-sm'>
Run chat-completion checks through the secure proxy to validate
model credentials and endpoint connectivity.
</p>
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[220px] w-full' />
<Skeleton className='h-[120px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for testing. Please try refreshing the
page.
</AlertDescription>
</Alert>
) : (
<ModelTester models={models} />
)}
</TabsContent>
<TabsContent value='test-api' className='mt-0 space-y-3'>
<div className='space-y-1'>
<h3 className='text-base font-semibold'>
OpenAI Endpoint Testing
</h3>
<p className='text-muted-foreground text-sm'>
Validate chat, embeddings, image, audio, and model-listing
endpoints through the secure proxy.
</p>
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[320px] w-full' />
<Skeleton className='h-[220px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for API testing. Please try refreshing
the page.
</AlertDescription>
</Alert>
) : (
<ApiEndpointTester models={models} />
)}
</TabsContent>
</Tabs>
</div>
</AppPageShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,11 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
import { Toaster } from 'sonner';
import { AuthProvider } from '@/lib/auth/AuthContext';
import { ProtectedRoute } from '@/lib/auth/ProtectedRoute';
import { AuthProvider } from '@/lib/auth/auth-context';
import { ProtectedRoute } from '@/lib/auth/protected-route';
import { ThemeProvider } from '@/components/theme-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
interface ProvidersProps {
children: ReactNode;
@@ -34,12 +35,14 @@ export function Providers({ children }: ProvidersProps) {
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</AuthProvider>
<TooltipProvider>
<AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</AuthProvider>
</TooltipProvider>
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
</QueryClientProvider>

File diff suppressed because it is too large Load Diff

View File

@@ -4,37 +4,30 @@ import * as React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
import { AdminSettings } from '@/components/settings/admin-settings';
import { SiteHeader } from '@/components/site-header';
import { AppSidebar } from '@/components/app-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Toaster } from 'sonner';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
export default function SettingsPage() {
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='flex items-center'>
<h1 className='text-2xl font-bold tracking-tight'>Settings</h1>
</div>
<Tabs defaultValue='admin' className='w-full'>
<TabsList className='mb-4'>
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
</TabsList>
<TabsContent value='server'>
<ServerConfigSettings />
</TabsContent>
<TabsContent value='admin'>
<AdminSettings />
</TabsContent>
</Tabs>
</div>
</div>
</SidebarInset>
<Toaster />
</SidebarProvider>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-6'>
<PageHeader
title='Settings'
description='Manage admin authentication, service metadata, and upstream forwarding.'
/>
<Tabs defaultValue='admin' className='w-full'>
<TabsList variant='line' className='mb-4 w-full'>
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
<TabsTrigger value='server'>Server Config</TabsTrigger>
</TabsList>
<TabsContent value='server'>
<ServerConfigSettings />
</TabsContent>
<TabsContent value='admin'>
<AdminSettings />
</TabsContent>
</Tabs>
</div>
</AppPageShell>
);
}

View File

@@ -1,353 +0,0 @@
'use client';
import { useState } from 'react';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useQuery } from '@tanstack/react-query';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
Copy,
RefreshCw,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client';
interface Transaction {
id: string;
created_at: string;
token: string;
amount: string;
}
interface PaginatedTransactionsResponse {
transactions: Transaction[];
total: number;
page: number;
per_page: number;
total_pages: number;
}
const TransactionService = {
getAllTransactions: async (): Promise<Transaction[]> => {
try {
const response = await apiClient.get<Transaction[]>('/api/transactions');
return response || [];
} catch (error) {
console.error('Failed to fetch transactions:', error);
throw new Error('Failed to fetch transactions');
}
},
getPaginatedTransactions: async (
page: number,
perPage: number
): Promise<PaginatedTransactionsResponse> => {
try {
const response = await apiClient.get<PaginatedTransactionsResponse>(
`/api/transactions/paginated/${page}/${perPage}`
);
return response;
} catch (error) {
console.error('Failed to fetch paginated transactions:', error);
throw new Error('Failed to fetch paginated transactions');
}
},
getRecentTransactions: async (limit: number): Promise<Transaction[]> => {
try {
const response = await apiClient.get<Transaction[]>(
`/api/transactions/recent/${limit}`
);
return response || [];
} catch (error) {
console.error('Failed to fetch recent transactions:', error);
throw new Error('Failed to fetch recent transactions');
}
},
};
export default function TransactionsPage() {
const [currentPage, setCurrentPage] = useState(1);
const perPage = 20;
// Fetch paginated transactions data
const {
data: paginationData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['transactions', currentPage, perPage],
queryFn: () =>
TransactionService.getPaginatedTransactions(currentPage, perPage),
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30000, // 30 seconds
});
const transactions = paginationData?.transactions || [];
const totalPages = paginationData?.total_pages || 0;
const total = paginationData?.total || 0;
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const formatAmount = (amount: string) => {
return `${parseInt(amount).toLocaleString()} msats`;
};
const truncateToken = (token: string) => {
if (token.length <= 20) return token;
return `${token.slice(0, 10)}...${token.slice(-10)}`;
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success('Token copied to clipboard!');
} catch (error) {
console.error('Failed to copy to clipboard:', error);
toast.error('Failed to copy token');
}
};
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
const goToPrevious = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const goToNext = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
return (
<TooltipProvider>
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<div>
<h1 className='text-2xl font-bold tracking-tight'>
Transaction History
</h1>
<p className='text-muted-foreground text-sm'>
View all Cashu token transactions processed by the system
</p>
</div>
<Button
onClick={() => refetch()}
variant='outline'
size='sm'
disabled={isLoading}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{isLoading ? (
<div className='space-y-4'>
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className='h-[120px] w-full' />
))}
</div>
) : error ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load transactions.{' '}
{error instanceof Error
? error.message
: 'Please check if the server is running and try refreshing the page.'}
</AlertDescription>
</Alert>
) : transactions.length === 0 ? (
<div className='py-8 text-center'>
<p className='text-muted-foreground'>
No transactions found.
</p>
</div>
) : (
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
Showing {(currentPage - 1) * perPage + 1} to{' '}
{Math.min(currentPage * perPage, total)} of {total}{' '}
transactions
</div>
<div className='text-muted-foreground text-sm'>
Page {currentPage} of {totalPages}
</div>
</div>
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[100px]'>ID</TableHead>
<TableHead>Date & Time</TableHead>
<TableHead>Amount</TableHead>
<TableHead className='w-[400px]'>
Cashu Token
</TableHead>
<TableHead className='w-[60px]'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((transaction) => (
<TableRow key={transaction.id}>
<TableCell className='font-mono text-xs'>
{transaction.id.slice(0, 8)}
</TableCell>
<TableCell>
<div className='text-sm'>
{formatDate(transaction.created_at)}
</div>
</TableCell>
<TableCell>
<Badge variant='secondary'>
{formatAmount(transaction.amount)}
</Badge>
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<p className='max-w-[300px] cursor-pointer truncate rounded px-1 py-0.5 font-mono text-xs hover:bg-gray-100'>
{truncateToken(transaction.token)}
</p>
</TooltipTrigger>
<TooltipContent className='max-w-md break-all'>
<p className='font-mono text-xs'>
{transaction.token}
</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell>
<Button
variant='ghost'
size='sm'
onClick={() =>
copyToClipboard(transaction.token)
}
className='h-8 w-8 p-0'
>
<Copy className='h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
Page {currentPage} of {totalPages}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
size='sm'
onClick={goToPrevious}
disabled={currentPage === 1}
>
<ChevronLeft className='mr-1 h-4 w-4' />
Previous
</Button>
{/* Page Numbers */}
<div className='flex items-center space-x-1'>
{Array.from(
{ length: Math.min(5, totalPages) },
(_, i) => {
const pageNumber =
currentPage <= 3
? i + 1
: currentPage >= totalPages - 2
? totalPages - 4 + i
: currentPage - 2 + i;
if (pageNumber < 1 || pageNumber > totalPages)
return null;
return (
<Button
key={pageNumber}
variant={
currentPage === pageNumber
? 'default'
: 'outline'
}
size='sm'
onClick={() => goToPage(pageNumber)}
className='h-9 w-9 p-0'
>
{pageNumber}
</Button>
);
}
)}
</div>
<Button
variant='outline'
size='sm'
onClick={goToNext}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className='ml-1 h-4 w-4' />
</Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
);
}

View File

@@ -3,30 +3,34 @@
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import { ShieldAlertIcon } from 'lucide-react';
import { AuthPageShell } from '@/components/auth-page-shell';
export default function UnauthorizedPage() {
const router = useRouter();
return (
<div className='bg-background flex min-h-screen flex-col items-center justify-center p-4'>
<div className='flex max-w-md flex-col items-center space-y-6 text-center'>
<ShieldAlertIcon className='text-destructive h-24 w-24' />
<h1 className='text-4xl font-bold'>Access Denied</h1>
<p className='text-muted-foreground text-lg'>
You don&apos;t have permission to access this page. Please contact
your administrator if you believe this is an error.
<AuthPageShell
title='Access Denied'
description="You don't have permission to access this page."
>
<div className='flex flex-col items-center gap-6'>
<ShieldAlertIcon className='text-destructive h-20 w-20' />
<p className='text-muted-foreground text-center text-sm'>
Contact your administrator if you believe this is an error.
</p>
<div className='flex gap-4'>
<Button onClick={() => router.push('/')}>Go to Dashboard</Button>
<Button variant='outline' onClick={() => router.back()}>
<div className='flex w-full flex-col gap-3 sm:w-auto sm:flex-row'>
<Button onClick={() => router.push('/')} className='w-full sm:w-auto'>
Go to Dashboard
</Button>
<Button
variant='outline'
onClick={() => router.back()}
className='w-full sm:w-auto'
>
Go Back
</Button>
</div>
</div>
</div>
</AuthPageShell>
);
}

View File

@@ -1,15 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "stone",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +19,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"registries": {}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -45,9 +46,10 @@ export function AddModelForm({
isOpen,
}: AddModelFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
type ManualModelInput = z.input<typeof ManualModelSchema>;
const form = useForm<ManualModel>({
resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
const form = useForm<ManualModelInput, unknown, ManualModel>({
resolver: zodResolver(ManualModelSchema),
defaultValues: {
name: '',
full_name: '',

View File

@@ -248,7 +248,9 @@ export function AddProviderModelDialog({
const pricing = model.pricing as Record<string, number>;
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('description', model.description || '');
form.setValue('context_length', model.context_length);
@@ -425,7 +427,7 @@ export function AddProviderModelDialog({
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
{!isEdit && (
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Presets</div>
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
@@ -488,8 +490,9 @@ export function AddProviderModelDialog({
</Popover>
</div>
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
{isOverride
? '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>
)}
@@ -805,45 +808,6 @@ export function AddProviderModelDialog({
</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>

View File

@@ -0,0 +1,699 @@
import type { ChangeEvent, Dispatch, SetStateAction } from 'react';
import Image from 'next/image';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Image as ImageIcon, List, Mic, MicOff, Volume2 } from 'lucide-react';
import type { EndpointType } from '@/components/api-endpoint-types';
interface ApiEndpointFormProps {
selectedEndpoint: EndpointType;
maxTokens: number;
setMaxTokens: Dispatch<SetStateAction<number>>;
temperature: number;
setTemperature: Dispatch<SetStateAction<number>>;
systemMessage: string;
setSystemMessage: Dispatch<SetStateAction<string>>;
userMessage: string;
setUserMessage: Dispatch<SetStateAction<string>>;
visionMaxTokens: number;
setVisionMaxTokens: Dispatch<SetStateAction<number>>;
visionTemperature: number;
setVisionTemperature: Dispatch<SetStateAction<number>>;
visionSystemMessage: string;
setVisionSystemMessage: Dispatch<SetStateAction<string>>;
visionUserMessage: string;
setVisionUserMessage: Dispatch<SetStateAction<string>>;
imageDetail: 'low' | 'high' | 'auto';
setImageDetail: Dispatch<SetStateAction<'low' | 'high' | 'auto'>>;
selectedImage: File | null;
imagePreviewUrl: string | null;
onImageUpload: (event: ChangeEvent<HTMLInputElement>) => void;
onRemoveImage: () => void;
embeddingInput: string;
setEmbeddingInput: Dispatch<SetStateAction<string>>;
encodingFormat: 'float' | 'base64';
setEncodingFormat: Dispatch<SetStateAction<'float' | 'base64'>>;
imageCount: number;
setImageCount: Dispatch<SetStateAction<number>>;
imageSize: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
setImageSize: Dispatch<
SetStateAction<
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
>
>;
imageQuality: 'standard' | 'hd';
setImageQuality: Dispatch<SetStateAction<'standard' | 'hd'>>;
imageStyle: 'vivid' | 'natural';
setImageStyle: Dispatch<SetStateAction<'vivid' | 'natural'>>;
imagePrompt: string;
setImagePrompt: Dispatch<SetStateAction<string>>;
speechVoice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
setSpeechVoice: Dispatch<
SetStateAction<'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'>
>;
speechFormat: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
setSpeechFormat: Dispatch<
SetStateAction<'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'>
>;
speechSpeed: number;
setSpeechSpeed: Dispatch<SetStateAction<number>>;
speechInput: string;
setSpeechInput: Dispatch<SetStateAction<string>>;
audioTranscriptionPrompt: string;
setAudioTranscriptionPrompt: Dispatch<SetStateAction<string>>;
audioTemperature: number;
setAudioTemperature: Dispatch<SetStateAction<number>>;
audioLanguage: string;
setAudioLanguage: Dispatch<SetStateAction<string>>;
audioResponseFormat: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
setAudioResponseFormat: Dispatch<
SetStateAction<'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'>
>;
isRecording: boolean;
onStartRecording: () => void;
onStopRecording: () => void;
onAudioUpload: (event: ChangeEvent<HTMLInputElement>) => void;
recordedAudio: File | null;
recordingUrl: string | null;
onRemoveAudio: () => void;
}
export function ApiEndpointForm({
selectedEndpoint,
maxTokens,
setMaxTokens,
temperature,
setTemperature,
systemMessage,
setSystemMessage,
userMessage,
setUserMessage,
visionMaxTokens,
setVisionMaxTokens,
visionTemperature,
setVisionTemperature,
visionSystemMessage,
setVisionSystemMessage,
visionUserMessage,
setVisionUserMessage,
imageDetail,
setImageDetail,
selectedImage,
imagePreviewUrl,
onImageUpload,
onRemoveImage,
embeddingInput,
setEmbeddingInput,
encodingFormat,
setEncodingFormat,
imageCount,
setImageCount,
imageSize,
setImageSize,
imageQuality,
setImageQuality,
imageStyle,
setImageStyle,
imagePrompt,
setImagePrompt,
speechVoice,
setSpeechVoice,
speechFormat,
setSpeechFormat,
speechSpeed,
setSpeechSpeed,
speechInput,
setSpeechInput,
audioTranscriptionPrompt,
setAudioTranscriptionPrompt,
audioTemperature,
setAudioTemperature,
audioLanguage,
setAudioLanguage,
audioResponseFormat,
setAudioResponseFormat,
isRecording,
onStartRecording,
onStopRecording,
onAudioUpload,
recordedAudio,
recordingUrl,
onRemoveAudio,
}: ApiEndpointFormProps) {
switch (selectedEndpoint) {
case 'chat-completions':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='max-tokens'>Max Tokens</Label>
<Input
id='max-tokens'
type='number'
min={1}
max={4000}
value={maxTokens}
onChange={(event) =>
setMaxTokens(parseInt(event.target.value) || 150)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='temperature'>Temperature</Label>
<Input
id='temperature'
type='number'
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(event) =>
setTemperature(parseFloat(event.target.value) || 0.7)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='system-message'>System Message (Optional)</Label>
<Textarea
id='system-message'
placeholder='Enter system message...'
value={systemMessage}
onChange={(event) => setSystemMessage(event.target.value)}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='user-message'>Test Message</Label>
<Textarea
id='user-message'
placeholder='Enter your test message...'
value={userMessage}
onChange={(event) => setUserMessage(event.target.value)}
rows={3}
/>
</div>
</div>
);
case 'vision-chat':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='vision-max-tokens'>Max Tokens</Label>
<Input
id='vision-max-tokens'
type='number'
min={1}
max={4000}
value={visionMaxTokens}
onChange={(event) =>
setVisionMaxTokens(parseInt(event.target.value) || 300)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-temperature'>Temperature</Label>
<Input
id='vision-temperature'
type='number'
min={0}
max={2}
step={0.1}
value={visionTemperature}
onChange={(event) =>
setVisionTemperature(parseFloat(event.target.value) || 0.7)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-system-message'>
System Message (Optional)
</Label>
<Textarea
id='vision-system-message'
placeholder='Enter system message...'
value={visionSystemMessage}
onChange={(event) => setVisionSystemMessage(event.target.value)}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-user-message'>Test Message</Label>
<Textarea
id='vision-user-message'
placeholder='Enter your test message...'
value={visionUserMessage}
onChange={(event) => setVisionUserMessage(event.target.value)}
rows={3}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='image-detail'>Image Detail</Label>
<Select
value={imageDetail}
onValueChange={(value: 'low' | 'high' | 'auto') =>
setImageDetail(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='low'>Low</SelectItem>
<SelectItem value='high'>High</SelectItem>
<SelectItem value='auto'>Auto</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-upload'>Upload Image (Optional)</Label>
<Input
type='file'
accept='image/*'
onChange={onImageUpload}
className='cursor-pointer'
/>
{selectedImage && (
<div className='mt-2 flex flex-wrap items-center gap-2'>
<ImageIcon className='text-muted-foreground h-5 w-5' />
<span className='text-muted-foreground text-sm'>
Selected image: {selectedImage.name}
</span>
<Button
variant='outline'
size='sm'
onClick={onRemoveImage}
className='ml-0 sm:ml-auto'
>
Remove
</Button>
</div>
)}
{imagePreviewUrl && (
<div className='relative mt-2 aspect-square w-32 overflow-hidden rounded-md border'>
<Image
src={imagePreviewUrl}
alt='Image preview'
fill
className='object-cover'
/>
</div>
)}
</div>
</div>
);
case 'embeddings':
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='encoding-format'>Encoding Format</Label>
<Select
value={encodingFormat}
onValueChange={(value: 'float' | 'base64') =>
setEncodingFormat(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='float'>Float</SelectItem>
<SelectItem value='base64'>Base64</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='embedding-input'>Text Input</Label>
<Textarea
id='embedding-input'
placeholder='Enter text to generate embeddings for...'
value={embeddingInput}
onChange={(event) => setEmbeddingInput(event.target.value)}
rows={4}
/>
</div>
</div>
);
case 'images':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='image-count'>Number of Images</Label>
<Input
id='image-count'
type='number'
min={1}
max={10}
value={imageCount}
onChange={(event) =>
setImageCount(parseInt(event.target.value) || 1)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='image-size'>Size</Label>
<Select
value={imageSize}
onValueChange={(
value:
| '256x256'
| '512x512'
| '1024x1024'
| '1792x1024'
| '1024x1792'
) => setImageSize(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='256x256'>256x256</SelectItem>
<SelectItem value='512x512'>512x512</SelectItem>
<SelectItem value='1024x1024'>1024x1024</SelectItem>
<SelectItem value='1792x1024'>1792x1024</SelectItem>
<SelectItem value='1024x1792'>1024x1792</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-quality'>Quality</Label>
<Select
value={imageQuality}
onValueChange={(value: 'standard' | 'hd') =>
setImageQuality(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='standard'>Standard</SelectItem>
<SelectItem value='hd'>HD</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='image-style'>Style</Label>
<Select
value={imageStyle}
onValueChange={(value: 'vivid' | 'natural') =>
setImageStyle(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='vivid'>Vivid</SelectItem>
<SelectItem value='natural'>Natural</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-prompt'>Image Prompt</Label>
<Textarea
id='image-prompt'
placeholder='Describe the image you want to generate...'
value={imagePrompt}
onChange={(event) => setImagePrompt(event.target.value)}
rows={3}
/>
</div>
</div>
);
case 'audio-speech':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='speech-voice'>Voice</Label>
<Select
value={speechVoice}
onValueChange={(
value:
| 'alloy'
| 'echo'
| 'fable'
| 'onyx'
| 'nova'
| 'shimmer'
) => setSpeechVoice(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='alloy'>Alloy</SelectItem>
<SelectItem value='echo'>Echo</SelectItem>
<SelectItem value='fable'>Fable</SelectItem>
<SelectItem value='onyx'>Onyx</SelectItem>
<SelectItem value='nova'>Nova</SelectItem>
<SelectItem value='shimmer'>Shimmer</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-format'>Response Format</Label>
<Select
value={speechFormat}
onValueChange={(
value: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
) => setSpeechFormat(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='mp3'>MP3</SelectItem>
<SelectItem value='opus'>Opus</SelectItem>
<SelectItem value='aac'>AAC</SelectItem>
<SelectItem value='flac'>FLAC</SelectItem>
<SelectItem value='wav'>WAV</SelectItem>
<SelectItem value='pcm'>PCM</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-speed'>Speed</Label>
<Input
id='speech-speed'
type='number'
min={0.25}
max={4.0}
step={0.25}
value={speechSpeed}
onChange={(event) =>
setSpeechSpeed(parseFloat(event.target.value) || 1.0)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-input'>Text to Synthesize</Label>
<Textarea
id='speech-input'
placeholder='Enter text to convert to speech...'
value={speechInput}
onChange={(event) => setSpeechInput(event.target.value)}
rows={4}
/>
</div>
</div>
);
case 'audio-transcription':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-prompt'>
Prompt (Optional)
</Label>
<Textarea
id='audio-transcription-prompt'
placeholder='Enter a prompt for the transcription...'
value={audioTranscriptionPrompt}
onChange={(event) =>
setAudioTranscriptionPrompt(event.target.value)
}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-temperature'>
Temperature
</Label>
<Input
id='audio-transcription-temperature'
type='number'
min={0}
max={1}
step={0.1}
value={audioTemperature}
onChange={(event) =>
setAudioTemperature(parseFloat(event.target.value) || 0.0)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-language'>
Language (Optional)
</Label>
<Input
id='audio-transcription-language'
placeholder='e.g., en-US, fr-FR'
value={audioLanguage}
onChange={(event) => setAudioLanguage(event.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-response-format'>
Response Format
</Label>
<Select
value={audioResponseFormat}
onValueChange={(
value: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
) => setAudioResponseFormat(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='json'>JSON</SelectItem>
<SelectItem value='text'>Text</SelectItem>
<SelectItem value='srt'>SRT</SelectItem>
<SelectItem value='verbose_json'>Verbose JSON</SelectItem>
<SelectItem value='vtt'>VTT</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Voice Recording</Label>
<div className='flex flex-wrap items-center gap-2'>
<Button
type='button'
variant={isRecording ? 'destructive' : 'default'}
size='sm'
onClick={isRecording ? onStopRecording : onStartRecording}
className='flex items-center gap-2'
>
{isRecording ? (
<>
<MicOff className='h-4 w-4' />
Stop Recording
</>
) : (
<>
<Mic className='h-4 w-4' />
Start Recording
</>
)}
</Button>
{isRecording && (
<Badge variant='destructive' className='gap-1.5'>
<span className='h-2 w-2 animate-pulse rounded-full bg-current' />
Recording...
</Badge>
)}
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-upload'>
Or Upload Audio File
</Label>
<Input
type='file'
accept='audio/*'
onChange={onAudioUpload}
className='cursor-pointer'
/>
{recordedAudio && (
<div className='mt-2 flex flex-wrap items-center gap-2'>
<Volume2 className='text-muted-foreground h-5 w-5' />
<span className='text-muted-foreground text-sm'>
Selected audio: {recordedAudio.name}
</span>
<Button
variant='outline'
size='sm'
onClick={onRemoveAudio}
className='ml-0 sm:ml-auto'
>
Remove
</Button>
</div>
)}
{recordingUrl && (
<div className='bg-muted mt-2 rounded-md p-4'>
<audio controls src={recordingUrl} className='w-full'>
Your browser does not support the audio element.
</audio>
</div>
)}
</div>
</div>
</div>
);
case 'models':
return (
<div className='space-y-4'>
<Alert>
<List className='h-4 w-4' />
<AlertDescription>
This endpoint lists all available models from the provider. No
additional parameters are required.
</AlertDescription>
</Alert>
</div>
);
default:
return null;
}
}

View File

@@ -0,0 +1,306 @@
import Image from 'next/image';
import { Label } from '@/components/ui/label';
export interface ChatCompletionResponse {
id: string;
object: string;
created: number;
model: string;
choices: {
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface EmbeddingResponse {
object: string;
data: {
object: string;
index: number;
embedding: number[];
}[];
model: string;
usage: {
prompt_tokens: number;
total_tokens: number;
};
}
export interface ImageGenerationResponse {
created: number;
data: {
url: string;
revised_prompt?: string;
}[];
}
export interface AudioResponse {
type: 'audio';
url: string;
size: number;
}
export interface AudioTranscriptionResponse {
text: string;
}
export interface ModelsListResponse {
object: string;
data: {
id: string;
object?: string;
created?: number;
}[];
}
export type ApiResponse =
| ChatCompletionResponse
| EmbeddingResponse
| ImageGenerationResponse
| AudioResponse
| AudioTranscriptionResponse
| ModelsListResponse;
const isAudioResponse = (response: ApiResponse): response is AudioResponse => {
return 'type' in response && response.type === 'audio';
};
const isChatCompletionResponse = (
response: ApiResponse
): response is ChatCompletionResponse => {
return 'choices' in response;
};
const isEmbeddingResponse = (
response: ApiResponse
): response is EmbeddingResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'embedding' in response.data[0]
);
};
const isImageGenerationResponse = (
response: ApiResponse
): response is ImageGenerationResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'url' in response.data[0]
);
};
const isModelsListResponse = (
response: ApiResponse
): response is ModelsListResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'id' in response.data[0]
);
};
const isAudioTranscriptionResponse = (
response: ApiResponse
): response is AudioTranscriptionResponse => {
return 'text' in response;
};
export function ApiEndpointResponse({
response,
}: {
response: ApiResponse | null;
}) {
if (!response) {
return null;
}
if (isAudioResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Generated Audio</Label>
<div className='bg-muted rounded-md p-4'>
<audio controls src={response.url} className='w-full'>
Your browser does not support the audio element.
</audio>
<p className='text-muted-foreground mt-2 text-sm'>
Audio file size: {(response.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
</div>
);
}
if (isAudioTranscriptionResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Transcription Result</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-sm whitespace-pre-wrap'>
{response.text || 'No transcription result available'}
</p>
</div>
</div>
</div>
);
}
if (isChatCompletionResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Model Response</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-sm whitespace-pre-wrap'>
{response.choices?.[0]?.message?.content ||
'No content in response'}
</p>
</div>
</div>
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='grid grid-cols-1 gap-2 text-sm sm:grid-cols-3 sm:gap-4'>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.prompt_tokens}
</div>
<div className='text-muted-foreground'>Prompt Tokens</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.completion_tokens}
</div>
<div className='text-muted-foreground'>Completion Tokens</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.total_tokens}
</div>
<div className='text-muted-foreground'>Total Tokens</div>
</div>
</div>
</div>
)}
</div>
);
}
if (isEmbeddingResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Embedding Vector</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
Generated {response.data?.[0]?.embedding?.length || 0} dimensional
embedding vector
</p>
<details className='group'>
<summary className='hover:text-foreground cursor-pointer text-sm'>
Show first 10 values
</summary>
<pre className='mt-2 text-xs'>
{JSON.stringify(
response.data?.[0]?.embedding?.slice(0, 10),
null,
2
)}
...
</pre>
</details>
</div>
</div>
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='bg-muted rounded p-2 text-center text-sm'>
<div className='font-semibold'>{response.usage.total_tokens}</div>
<div className='text-muted-foreground'>Total Tokens</div>
</div>
</div>
)}
</div>
);
}
if (isImageGenerationResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Generated Images</Label>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
{response.data?.map((image, index: number) => (
<div key={index} className='space-y-2'>
<div className='relative aspect-square w-full'>
<Image
src={image.url}
alt={`Generated image ${index + 1}`}
fill
className='rounded-md border object-cover'
unoptimized={true}
/>
</div>
{image.revised_prompt && (
<p className='text-muted-foreground text-xs'>
Revised prompt: {image.revised_prompt}
</p>
)}
</div>
))}
</div>
</div>
</div>
);
}
if (isModelsListResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Available Models</Label>
<div className='max-h-60 overflow-auto'>
<div className='grid gap-2'>
{response.data?.map((model) => (
<div key={model.id} className='bg-muted rounded-md p-3'>
<div className='font-medium'>{model.id}</div>
{model.object && (
<div className='text-muted-foreground text-sm'>
Type: {model.object}
</div>
)}
{model.created && (
<div className='text-muted-foreground text-sm'>
Created:{' '}
{new Date(model.created * 1000).toLocaleDateString()}
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,726 @@
'use client';
import React, { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { type Model } from '@/lib/api/schemas/models';
import { ModelService } from '@/lib/api/services/models';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Loader2,
Send,
CheckCircle,
XCircle,
Info,
Key,
Globe,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ApiEndpointResponse,
type ApiResponse,
} from '@/components/api-endpoint-response';
import { ApiEndpointForm } from '@/components/api-endpoint-form';
import {
API_ENDPOINTS,
DEFAULT_REQUESTS,
type EndpointType,
type ChatCompletionRequest,
type EmbeddingRequest,
type ImageGenerationRequest,
type AudioSpeechRequest,
type AudioTranscriptionRequest,
type EndpointRequestData,
} from '@/components/api-endpoint-types';
interface ApiEndpointTesterProps {
models: Model[];
}
export function ApiEndpointTester({ models }: ApiEndpointTesterProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [selectedEndpoint, setSelectedEndpoint] =
useState<EndpointType>('chat-completions');
const [response, setResponse] = useState<ApiResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Chat Completions state
const [systemMessage, setSystemMessage] = useState(
DEFAULT_REQUESTS['chat-completions'].systemMessage
);
const [userMessage, setUserMessage] = useState(
DEFAULT_REQUESTS['chat-completions'].userMessage
);
const [maxTokens, setMaxTokens] = useState(
DEFAULT_REQUESTS['chat-completions'].maxTokens
);
const [temperature, setTemperature] = useState(
DEFAULT_REQUESTS['chat-completions'].temperature
);
// Vision Chat state
const [visionSystemMessage, setVisionSystemMessage] = useState(
DEFAULT_REQUESTS['vision-chat'].systemMessage
);
const [visionUserMessage, setVisionUserMessage] = useState(
DEFAULT_REQUESTS['vision-chat'].userMessage
);
const [visionMaxTokens, setVisionMaxTokens] = useState(
DEFAULT_REQUESTS['vision-chat'].maxTokens
);
const [visionTemperature, setVisionTemperature] = useState(
DEFAULT_REQUESTS['vision-chat'].temperature
);
const [imageDetail, setImageDetail] = useState<'low' | 'high' | 'auto'>(
DEFAULT_REQUESTS['vision-chat'].imageDetail
);
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
// Voice Recording state
const [isRecording, setIsRecording] = useState(false);
const [recordedAudio, setRecordedAudio] = useState<File | null>(null);
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(
null
);
const [audioTranscriptionPrompt, setAudioTranscriptionPrompt] = useState(
DEFAULT_REQUESTS['audio-transcription'].prompt
);
const [audioResponseFormat, setAudioResponseFormat] = useState<
'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
>(DEFAULT_REQUESTS['audio-transcription'].response_format);
const [audioTemperature, setAudioTemperature] = useState(
DEFAULT_REQUESTS['audio-transcription'].temperature
);
const [audioLanguage, setAudioLanguage] = useState(
DEFAULT_REQUESTS['audio-transcription'].language
);
// Embeddings state
const [embeddingInput, setEmbeddingInput] = useState(
DEFAULT_REQUESTS.embeddings.input
);
const [encodingFormat, setEncodingFormat] = useState<'float' | 'base64'>(
DEFAULT_REQUESTS.embeddings.encoding_format
);
// Image Generation state
const [imagePrompt, setImagePrompt] = useState(
DEFAULT_REQUESTS.images.prompt
);
const [imageCount, setImageCount] = useState(DEFAULT_REQUESTS.images.n);
const [imageSize, setImageSize] = useState<
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
>(DEFAULT_REQUESTS.images.size);
const [imageQuality, setImageQuality] = useState<'standard' | 'hd'>(
DEFAULT_REQUESTS.images.quality
);
const [imageStyle, setImageStyle] = useState<'vivid' | 'natural'>(
DEFAULT_REQUESTS.images.style
);
// Audio Speech state
const [speechInput, setSpeechInput] = useState(
DEFAULT_REQUESTS['audio-speech'].input
);
const [speechVoice, setSpeechVoice] = useState<
'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'
>(DEFAULT_REQUESTS['audio-speech'].voice);
const [speechFormat, setSpeechFormat] = useState<
'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
>(DEFAULT_REQUESTS['audio-speech'].response_format);
const [speechSpeed, setSpeechSpeed] = useState(
DEFAULT_REQUESTS['audio-speech'].speed
);
// Fetch model groups for API key resolution
const { data: groups = [] } = useQuery({
queryKey: ['model-groups'],
queryFn: () => ModelService.getModelGroups(),
refetchOnWindowFocus: false,
});
const selectedModel = models.find((model) => model.id === selectedModelId);
// Image upload handler
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith('image/')) {
setSelectedImage(file);
const previewUrl = URL.createObjectURL(file);
setImagePreviewUrl(previewUrl);
} else {
toast.error('Please select a valid image file');
}
}
};
// Audio file upload handler
const handleAudioUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith('audio/')) {
setRecordedAudio(file);
const audioUrl = URL.createObjectURL(file);
setRecordingUrl(audioUrl);
} else {
toast.error('Please select a valid audio file');
}
}
};
// Voice recording functions
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
const file = new File([blob], 'recording.webm', { type: 'audio/webm' });
setRecordedAudio(file);
const audioUrl = URL.createObjectURL(blob);
setRecordingUrl(audioUrl);
// Stop all tracks to release microphone
stream.getTracks().forEach((track) => track.stop());
};
recorder.start();
setMediaRecorder(recorder);
setIsRecording(true);
toast.success('Recording started');
} catch {
toast.error(
'Failed to start recording. Please check microphone permissions.'
);
}
};
const stopRecording = () => {
if (mediaRecorder) {
mediaRecorder.stop();
setMediaRecorder(null);
setIsRecording(false);
toast.success('Recording stopped');
}
};
// Convert file to base64 for vision API
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
// Get effective API key and endpoint URL for the selected model
const getModelCredentials = (model: Model) => {
const group = groups.find((g) => g.provider === model.provider);
// Determine API key (individual takes precedence over group)
const apiKey = model.api_key || group?.group_api_key;
// Determine base endpoint URL
let baseUrl = model.url;
// If model URL is relative and group has a base URL, combine them
if (model.url.startsWith('/') && group?.group_url) {
baseUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`;
}
// Remove any existing endpoint path to get base URL
baseUrl = baseUrl.replace(/\/v1\/.*$/, '').replace(/\/$/, '');
return {
apiKey,
baseUrl,
group,
};
};
const buildEndpointUrl = (baseUrl: string, endpointPath: string) => {
return `${baseUrl}${endpointPath}`;
};
const buildRequest = async (): Promise<EndpointRequestData> => {
if (!selectedModel) return null;
switch (selectedEndpoint) {
case 'chat-completions':
const messages = [];
if (systemMessage.trim()) {
messages.push({
role: 'system' as const,
content: systemMessage.trim(),
});
}
messages.push({ role: 'user' as const, content: userMessage.trim() });
return {
model: selectedModel.name,
messages,
max_tokens: maxTokens,
temperature: temperature,
} as ChatCompletionRequest;
case 'vision-chat':
if (!selectedImage) {
throw new Error('Please select an image for vision analysis');
}
const imageBase64 = await fileToBase64(selectedImage);
const visionMessages = [];
if (visionSystemMessage.trim()) {
visionMessages.push({
role: 'system' as const,
content: visionSystemMessage.trim(),
});
}
visionMessages.push({
role: 'user' as const,
content: [
{
type: 'text' as const,
text: visionUserMessage.trim(),
},
{
type: 'image_url' as const,
image_url: {
url: imageBase64,
detail: imageDetail,
},
},
],
});
return {
model: selectedModel.name,
messages: visionMessages,
max_tokens: visionMaxTokens,
temperature: visionTemperature,
} as ChatCompletionRequest;
case 'embeddings':
return {
model: selectedModel.name,
input: embeddingInput,
encoding_format: encodingFormat,
} as EmbeddingRequest;
case 'images':
return {
model: selectedModel.name,
prompt: imagePrompt,
n: imageCount,
size: imageSize,
quality: imageQuality,
style: imageStyle,
} as ImageGenerationRequest;
case 'audio-speech':
return {
model: selectedModel.name,
input: speechInput,
voice: speechVoice,
response_format: speechFormat,
speed: speechSpeed,
} as AudioSpeechRequest;
case 'audio-transcription':
if (!recordedAudio) {
throw new Error(
'Please record or upload an audio file for transcription'
);
}
return {
model: selectedModel.name,
file: recordedAudio,
prompt: audioTranscriptionPrompt.trim() || undefined,
response_format: audioResponseFormat,
temperature: audioTemperature,
language: audioLanguage.trim() || undefined,
} as AudioTranscriptionRequest;
case 'models':
return null; // No request body needed for models endpoint
default:
return null;
}
};
const testEndpointMutation = useMutation({
mutationFn: async (requestData: EndpointRequestData) => {
if (!selectedModel) {
throw new Error('No model selected');
}
setError(null);
setResponse(null);
try {
const response = await ModelService.testModel(
selectedModel.id,
selectedEndpoint,
requestData
);
if (!response.success) {
throw new Error(response.error || 'Test failed');
}
return response.data as ApiResponse;
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to test endpoint via proxy';
throw new Error(errorMessage);
}
},
onSuccess: (data) => {
setResponse(data);
toast.success(
`${API_ENDPOINTS[selectedEndpoint].name} test completed successfully!`
);
},
onError: (err: Error) => {
const errorMessage = err?.message || 'Unknown error occurred';
setError(errorMessage);
toast.error(
`${API_ENDPOINTS[selectedEndpoint].name} test failed: ${errorMessage}`
);
},
});
const handleTest = async () => {
if (!selectedModel) {
toast.error('Please select a model to test');
return;
}
// Validate required fields based on endpoint
if (selectedEndpoint === 'chat-completions' && !userMessage.trim()) {
toast.error('Please enter a test message');
return;
}
if (selectedEndpoint === 'vision-chat') {
if (!visionUserMessage.trim()) {
toast.error('Please enter a test message');
return;
}
if (!selectedImage) {
toast.error('Please select an image for vision analysis');
return;
}
}
if (selectedEndpoint === 'embeddings' && !embeddingInput.trim()) {
toast.error('Please enter text for embedding');
return;
}
if (selectedEndpoint === 'images' && !imagePrompt.trim()) {
toast.error('Please enter an image prompt');
return;
}
if (selectedEndpoint === 'audio-speech' && !speechInput.trim()) {
toast.error('Please enter text for speech synthesis');
return;
}
if (selectedEndpoint === 'audio-transcription' && !recordedAudio) {
toast.error('Please record or upload an audio file for transcription');
return;
}
const requestData = await buildRequest();
testEndpointMutation.mutate(requestData);
};
const enabledModels = models.filter((model) => model.isEnabled);
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
const endpointUrl = credentials
? buildEndpointUrl(
credentials.baseUrl,
API_ENDPOINTS[selectedEndpoint].path
)
: '';
return (
<Card className='w-full'>
<CardHeader>
<CardTitle>API Endpoint Tester</CardTitle>
<CardDescription>
Comprehensive testing of OpenAI-compatible API endpoints through the
secure proxy (resolves CORS and network issues)
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
{/* Model and Endpoint Selection */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='model-select'>Select Model</Label>
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger id='model-select'>
<SelectValue placeholder='Choose a model to test...' />
</SelectTrigger>
<SelectContent>
{enabledModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className='flex items-center gap-2'>
<span>{model.name}</span>
<Badge variant='outline' className='text-xs'>
{model.provider}
</Badge>
{model.is_free && (
<Badge variant='secondary' className='text-xs'>
Free
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='endpoint-select'>API Endpoint</Label>
<Select
value={selectedEndpoint}
onValueChange={(value: EndpointType) =>
setSelectedEndpoint(value)
}
>
<SelectTrigger id='endpoint-select'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(API_ENDPOINTS).map(([key, endpoint]) => {
const Icon = endpoint.icon;
return (
<SelectItem key={key} value={key}>
<div className='flex items-center gap-2'>
<Icon className='h-4 w-4' />
<span>{endpoint.name}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
{/* Model Information */}
{selectedModel && credentials && (
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
<div className='flex items-center gap-2'>
<Globe className='h-4 w-4' />
<span className='break-all'>
<strong>Endpoint:</strong> {endpointUrl}
</span>
</div>
<div className='flex items-center gap-2'>
<Key className='h-4 w-4' />
<span>
<strong>API Key:</strong>{' '}
{credentials.apiKey
? `${credentials.apiKey.substring(0, 8)}...`
: 'Not configured'}
</span>
<Badge
variant={credentials.apiKey ? 'default' : 'destructive'}
className='text-xs'
>
{selectedModel.api_key_type || 'Unknown'}
</Badge>
</div>
<div>
<span>
<strong>Provider:</strong> {selectedModel.provider}
</span>
</div>
<div>
<span>
<strong>Description:</strong>{' '}
{API_ENDPOINTS[selectedEndpoint].description}
</span>
</div>
{!credentials.apiKey && (
<Alert variant='default' className='mt-2'>
<Info className='h-4 w-4' />
<AlertDescription>
No API key configured for this model. Testing may still work
if the model is free or if authentication is handled
elsewhere. For models requiring authentication, please add an
API key to the model or its provider group.
</AlertDescription>
</Alert>
)}
</div>
)}
<ApiEndpointForm
selectedEndpoint={selectedEndpoint}
maxTokens={maxTokens}
setMaxTokens={setMaxTokens}
temperature={temperature}
setTemperature={setTemperature}
systemMessage={systemMessage}
setSystemMessage={setSystemMessage}
userMessage={userMessage}
setUserMessage={setUserMessage}
visionMaxTokens={visionMaxTokens}
setVisionMaxTokens={setVisionMaxTokens}
visionTemperature={visionTemperature}
setVisionTemperature={setVisionTemperature}
visionSystemMessage={visionSystemMessage}
setVisionSystemMessage={setVisionSystemMessage}
visionUserMessage={visionUserMessage}
setVisionUserMessage={setVisionUserMessage}
imageDetail={imageDetail}
setImageDetail={setImageDetail}
selectedImage={selectedImage}
imagePreviewUrl={imagePreviewUrl}
onImageUpload={handleImageUpload}
onRemoveImage={() => {
setSelectedImage(null);
setImagePreviewUrl(null);
}}
embeddingInput={embeddingInput}
setEmbeddingInput={setEmbeddingInput}
encodingFormat={encodingFormat}
setEncodingFormat={setEncodingFormat}
imageCount={imageCount}
setImageCount={setImageCount}
imageSize={imageSize}
setImageSize={setImageSize}
imageQuality={imageQuality}
setImageQuality={setImageQuality}
imageStyle={imageStyle}
setImageStyle={setImageStyle}
imagePrompt={imagePrompt}
setImagePrompt={setImagePrompt}
speechVoice={speechVoice}
setSpeechVoice={setSpeechVoice}
speechFormat={speechFormat}
setSpeechFormat={setSpeechFormat}
speechSpeed={speechSpeed}
setSpeechSpeed={setSpeechSpeed}
speechInput={speechInput}
setSpeechInput={setSpeechInput}
audioTranscriptionPrompt={audioTranscriptionPrompt}
setAudioTranscriptionPrompt={setAudioTranscriptionPrompt}
audioTemperature={audioTemperature}
setAudioTemperature={setAudioTemperature}
audioLanguage={audioLanguage}
setAudioLanguage={setAudioLanguage}
audioResponseFormat={audioResponseFormat}
setAudioResponseFormat={setAudioResponseFormat}
isRecording={isRecording}
onStartRecording={startRecording}
onStopRecording={stopRecording}
onAudioUpload={handleAudioUpload}
recordedAudio={recordedAudio}
recordingUrl={recordingUrl}
onRemoveAudio={() => {
setRecordedAudio(null);
setRecordingUrl(null);
}}
/>
{/* Test Button */}
<Button
onClick={handleTest}
disabled={!selectedModelId || testEndpointMutation.isPending}
className='w-full'
>
{testEndpointMutation.isPending ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Testing {API_ENDPOINTS[selectedEndpoint].name}...
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Test {API_ENDPOINTS[selectedEndpoint].name}
</>
)}
</Button>
{/* Results */}
{error && (
<Alert variant='destructive'>
<XCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Failed:</strong> {error}
</AlertDescription>
</Alert>
)}
{response && (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Successful!</strong>{' '}
{API_ENDPOINTS[selectedEndpoint].name} endpoint responded
correctly.
</AlertDescription>
</Alert>
)}
<ApiEndpointResponse response={response} />
{response && (
<div className='space-y-2'>
<Label>Raw Response</Label>
<details className='group'>
<summary className='text-muted-foreground hover:text-foreground cursor-pointer text-sm'>
<Info className='mr-1 inline h-4 w-4' />
Show detailed response data
</summary>
<pre className='bg-muted mt-2 max-h-60 overflow-auto rounded-md p-4 text-xs'>
{JSON.stringify(response, null, 2)}
</pre>
</details>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,156 @@
import {
FileText,
Eye,
List,
Image as ImageIcon,
Mic,
Volume2,
} from 'lucide-react';
export const API_ENDPOINTS = {
'chat-completions': {
name: 'Chat Completions',
path: '/chat/completions',
icon: FileText,
description: 'Test conversational AI with chat completion requests',
},
'vision-chat': {
name: 'Vision Chat (Image + Text)',
path: '/chat/completions',
icon: Eye,
description: 'Analyze images with text prompts using vision models',
},
embeddings: {
name: 'Embeddings',
path: '/embeddings',
icon: List,
description: 'Generate embeddings for text input',
},
images: {
name: 'Image Generation',
path: '/images/generations',
icon: ImageIcon,
description: 'Generate images from text prompts',
},
'audio-speech': {
name: 'Text-to-Speech',
path: '/audio/speech',
icon: Mic,
description: 'Convert text to speech audio',
},
'audio-transcription': {
name: 'Audio Transcription',
path: '/audio/transcriptions',
icon: Volume2,
description: 'Transcribe audio files to text',
},
models: {
name: 'List Models',
path: '/models',
icon: List,
description: 'List all available models from the provider',
},
} as const;
export type EndpointType = keyof typeof API_ENDPOINTS;
export interface ChatCompletionRequest {
model: string;
messages: {
role: 'system' | 'user' | 'assistant';
content:
| string
| Array<{
type: 'text' | 'image_url';
text?: string;
image_url?: {
url: string;
detail?: 'low' | 'high' | 'auto';
};
}>;
}[];
max_tokens?: number;
temperature?: number;
}
export interface EmbeddingRequest {
model: string;
input: string | string[];
encoding_format?: 'float' | 'base64';
}
export interface ImageGenerationRequest {
model?: string;
prompt: string;
n?: number;
size?: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
quality?: 'standard' | 'hd';
style?: 'vivid' | 'natural';
}
export interface AudioSpeechRequest {
model: string;
input: string;
voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
response_format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
speed?: number;
}
export interface AudioTranscriptionRequest {
model: string;
file: File;
prompt?: string;
response_format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
temperature?: number;
language?: string;
}
export const DEFAULT_REQUESTS = {
'chat-completions': {
systemMessage: 'You are a helpful assistant. Please respond concisely.',
userMessage:
'Hello! Can you tell me what model you are and confirm that you are working correctly?',
maxTokens: 150,
temperature: 0.7,
},
'vision-chat': {
systemMessage:
'You are a helpful assistant that can analyze images. Please describe what you see.',
userMessage:
'What do you see in this image? Please provide a detailed description.',
maxTokens: 300,
temperature: 0.7,
imageDetail: 'auto' as const,
},
embeddings: {
input: 'The quick brown fox jumps over the lazy dog.',
encoding_format: 'float' as const,
},
images: {
prompt: 'A beautiful sunset over a mountain landscape',
n: 1,
size: '1024x1024' as const,
quality: 'standard' as const,
style: 'vivid' as const,
},
'audio-speech': {
input: 'Hello, this is a test of the text-to-speech functionality.',
voice: 'alloy' as const,
response_format: 'mp3' as const,
speed: 1.0,
},
'audio-transcription': {
prompt: 'This is a test transcription.',
response_format: 'json' as const,
temperature: 0.0,
language: '',
},
};
export type EndpointRequestData =
| ChatCompletionRequest
| EmbeddingRequest
| ImageGenerationRequest
| AudioSpeechRequest
| AudioTranscriptionRequest
| null;

View File

@@ -0,0 +1,351 @@
'use client';
import { useState, type ReactNode } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
DatabaseIcon,
FileTextIcon,
LayoutDashboardIcon,
LogOutIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
ServerIcon,
SettingsIcon,
WalletIcon,
} from 'lucide-react';
import Image from 'next/image';
import { toast } from 'sonner';
import { adminLogout } from '@/lib/api/services/auth';
import { Button } from '@/components/ui/button';
import { CurrencyToggle } from '@/components/currency-toggle';
import { ThemeToggle } from '@/components/theme-toggle';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
interface AppPageShellProps {
children: ReactNode;
className?: string;
contentClassName?: string;
}
const NAV_ITEMS = [
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
{ title: 'Balances', url: '/balances', icon: WalletIcon },
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
{ title: 'Models', url: '/models', icon: DatabaseIcon },
{ title: 'Providers', url: '/providers', icon: ServerIcon },
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
] as const;
function isActivePath(pathname: string, itemUrl: string): boolean {
if (itemUrl === '/') {
return pathname === '/';
}
return pathname === itemUrl || pathname.startsWith(`${itemUrl}/`);
}
export function AppPageShell({
children,
className,
contentClassName,
}: AppPageShellProps) {
const pathname = usePathname();
const router = useRouter();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const handleLogout = async (): Promise<void> => {
try {
await adminLogout();
toast.success('Logged out successfully');
router.push('/login');
} catch (error) {
console.error('Logout error:', error);
toast.error('Failed to logout');
}
};
return (
<div className='bg-background text-foreground min-h-dvh overflow-x-clip md:h-screen md:overflow-hidden'>
<div className='flex min-h-dvh w-full min-w-0 overflow-x-clip md:h-full'>
<aside
className={cn(
'border-border/60 hidden shrink-0 border-r py-5 transition-[width,padding] duration-300 ease-in-out md:flex md:h-full md:flex-col md:overflow-y-auto',
isSidebarCollapsed ? 'w-16 px-2' : 'w-60 px-4'
)}
>
<div
className={cn(
'px-1 transition-[padding] duration-300 ease-in-out',
isSidebarCollapsed && 'px-0'
)}
>
<div className='flex items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='shrink-0 rounded-sm'
/>
<div
className={cn(
'min-w-0 overflow-hidden transition-[max-width,opacity,transform] duration-300 ease-in-out',
isSidebarCollapsed
? 'max-w-0 -translate-x-1 opacity-0'
: 'max-w-[11rem] translate-x-0 opacity-100'
)}
>
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
Routstr Node
</h1>
</div>
</div>
<Button
variant='ghost'
size='icon'
className={cn(
'text-muted-foreground hover:text-foreground h-8 w-8 shrink-0 transition-transform duration-300 ease-in-out',
isSidebarCollapsed ? 'mx-auto' : '-mr-1 ml-auto'
)}
onClick={() => setIsSidebarCollapsed((current) => !current)}
>
{isSidebarCollapsed ? (
<PanelLeftOpenIcon className='h-4 w-4' />
) : (
<PanelLeftCloseIcon className='h-4 w-4' />
)}
<span className='sr-only'>
{isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
</span>
</Button>
</div>
</div>
<nav
className={cn(
'mt-5',
isSidebarCollapsed
? 'flex flex-col items-center space-y-2'
: 'space-y-1.5'
)}
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActivePath(pathname, item.url);
return (
<Button
key={item.url}
asChild
variant={active ? 'outline' : 'ghost'}
className={cn(
'h-10 rounded-lg transition-[width,padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'mx-auto w-10 justify-center px-0'
: 'w-full justify-start'
)}
>
<Link href={item.url}>
<Icon className='h-4 w-4 shrink-0' />
<span
className={cn(
'overflow-hidden whitespace-nowrap transition-[max-width,opacity,margin] duration-300 ease-in-out',
isSidebarCollapsed
? 'ml-0 max-w-0 opacity-0'
: 'ml-0.5 max-w-[9rem] opacity-100'
)}
>
{item.title}
</span>
</Link>
</Button>
);
})}
</nav>
<div
className={cn(
'mt-auto pt-3 transition-[padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'flex flex-col items-center space-y-1.5'
: 'space-y-2'
)}
>
{isSidebarCollapsed ? (
<>
<CurrencyToggle
compact
menuSide='right'
menuAlign='start'
className='mx-auto'
/>
<ThemeToggle
compact
menuSide='right'
menuAlign='start'
className='mx-auto'
/>
<Button
variant='ghost'
size='sm'
onClick={handleLogout}
className='text-muted-foreground hover:text-foreground mx-auto h-8 w-10 justify-center px-0'
>
<LogOutIcon className='h-4 w-4' />
<span className='sr-only'>Logout</span>
</Button>
</>
) : (
<div className='border-border/60 bg-card/30 space-y-1 rounded-lg border p-1'>
<CurrencyToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<ThemeToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<Button
variant='ghost'
size='sm'
onClick={handleLogout}
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-8 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
>
<LogOutIcon className='h-4 w-4' />
Logout
</Button>
</div>
)}
</div>
</aside>
<section className='relative flex w-full min-w-0 flex-1 flex-col overflow-x-clip md:h-full md:min-h-0'>
<div className='bg-background/80 supports-[backdrop-filter]:bg-background/72 sticky top-0 z-30 flex items-center gap-2 px-3 py-2 backdrop-blur-xl md:hidden'>
<Button
type='button'
variant='outline'
size='sm'
className='h-9 gap-2 rounded-lg px-3'
onClick={() => setIsMobileSidebarOpen(true)}
>
<PanelLeftOpenIcon className='h-4 w-4' />
Menu
</Button>
</div>
<main
className={cn(
'w-full min-w-0 flex-1 overflow-x-clip p-3 pb-4 sm:p-4 md:min-h-0 md:overflow-y-auto md:p-6 md:pb-6',
contentClassName,
className
)}
>
{children}
</main>
</section>
</div>
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
<SheetContent
side='left'
showCloseButton={false}
className='w-[min(88vw,18rem)] p-0 md:hidden'
>
<SheetTitle className='sr-only'>Navigation sidebar</SheetTitle>
<SheetDescription className='sr-only'>
Browse admin pages and access sidebar controls.
</SheetDescription>
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border/60 px-4 pt-4 pb-3'>
<div className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 items-center gap-2'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded-sm'
/>
<p className='truncate text-base font-medium tracking-tight'>
Routstr Node
</p>
</div>
<SheetClose asChild>
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground h-8 w-8 shrink-0'
>
<PanelLeftCloseIcon className='h-4 w-4' />
<span className='sr-only'>Close sidebar</span>
</Button>
</SheetClose>
</div>
</div>
<div className='flex h-full min-h-0 flex-col px-3 pb-3'>
<nav className='space-y-1.5 py-3'>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActivePath(pathname, item.url);
return (
<Button
key={`mobile-sidebar-${item.url}`}
asChild
variant={active ? 'outline' : 'ghost'}
className='h-10 w-full justify-start rounded-lg'
>
<Link
href={item.url}
onClick={() => setIsMobileSidebarOpen(false)}
>
<Icon className='h-4 w-4' />
{item.title}
</Link>
</Button>
);
})}
</nav>
<div className='border-border/60 bg-card/30 mt-auto space-y-1 rounded-lg border p-1'>
<CurrencyToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<ThemeToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<Button
variant='ghost'
size='sm'
onClick={async () => {
setIsMobileSidebarOpen(false);
await handleLogout();
}}
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-9 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
>
<LogOutIcon className='h-4 w-4' />
Logout
</Button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import * as React from 'react';
import {
ExternalLinkIcon,
FileTextIcon,
DatabaseIcon,
LayoutDashboardIcon,
@@ -10,15 +11,18 @@ import {
WalletIcon,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { NavSecondary } from '@/components/nav-secondary';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
const data = {
@@ -48,7 +52,7 @@ const data = {
},
{
title: 'Models',
url: '/model',
url: '/models',
icon: DatabaseIcon,
},
{
@@ -68,29 +72,61 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible='offcanvas' {...props}>
<SidebarHeader>
<SidebarHeader className='px-3 pt-4 pb-3'>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className='data-[slot=sidebar-menu-button]:!p-1.5'
>
<div className='flex items-center gap-2'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded'
/>
<span className='text-base font-semibold'>Routstr Node</span>
<div className='flex items-center gap-2 px-2 py-1'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded'
/>
<div className='space-y-0.5'>
<p className='text-sm font-semibold tracking-tight'>
Routstr Node
</p>
<p className='text-muted-foreground text-[11px]'>
Admin dashboard
</p>
</div>
</SidebarMenuButton>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className='flex-1 overflow-y-auto'>
<NavSecondary items={data.navSecondary} className='mt-auto' />
<SidebarContent className='flex-1 overflow-y-auto px-2 pb-2'>
<NavSecondary items={data.navSecondary} />
<SidebarGroup className='mt-auto px-0 pt-2 pb-0'>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
<Link
href='https://docs.routstr.com'
target='_blank'
rel='noreferrer'
>
<span>Docs</span>
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
<Link
href='https://chat.routstr.com'
target='_blank'
rel='noreferrer'
>
<span>Chat App</span>
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{/*
<SidebarFooter>

View File

@@ -0,0 +1,38 @@
'use client';
import type { ReactNode } from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
interface AuthPageShellProps {
title: string;
description: string;
children: ReactNode;
}
export function AuthPageShell({
title,
description,
children,
}: AuthPageShellProps) {
return (
<div className='bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10 sm:py-12'>
<Card className='w-full max-w-md'>
<CardHeader className='space-y-1 pb-4'>
<CardTitle className='text-center text-2xl font-bold'>
{title}
</CardTitle>
<CardDescription className='text-center'>
{description}
</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
import { AdminService } from '@/lib/api/services/admin';
import { Loader2, Database } from 'lucide-react';
export interface BatchOverrideDialogProps {
providerId: number;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function BatchOverrideDialog({
providerId,
isOpen,
onClose,
onSuccess,
}: BatchOverrideDialogProps) {
const [jsonInput, setJsonInput] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const sampleJson = {
models: [
{
id: 'model-id-1',
name: 'Model Name 1',
description: 'Description...',
created: Math.floor(Date.now() / 1000),
context_length: 8192,
architecture: {
modality: 'text',
input_modalities: ['text'],
output_modalities: ['text'],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: 0.0,
completion: 0.0,
request: 0.0,
image: 0.0,
web_search: 0.0,
internal_reasoning: 0.0,
},
enabled: true,
},
],
};
const handleBatchOverride = async () => {
if (!jsonInput.trim()) {
toast.error('Please enter JSON content');
return;
}
setIsSubmitting(true);
try {
let data;
try {
data = JSON.parse(jsonInput);
} catch {
throw new Error('Invalid JSON format');
}
if (!data.models || !Array.isArray(data.models)) {
throw new Error('JSON match follow structure: { "models": [...] }');
}
const result = await AdminService.batchOverrideProviderModels(
providerId,
data.models
);
if (result.ok) {
toast.success(result.message || 'Batch override successful');
onSuccess();
onClose();
setJsonInput('');
} else {
throw new Error('Batch override failed');
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Batch override failed';
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='sm:max-w-[800px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Database className='h-4 w-4' />
Batch Override Models
</DialogTitle>
<DialogDescription>
Paste a JSON object with a &quot;models&quot; array containing model
definitions. Existing models with the same ID will be updated.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<Textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder={JSON.stringify(sampleJson, null, 2)}
className='min-h-[260px] font-mono text-xs sm:min-h-[400px]'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
<Button onClick={handleBatchOverride} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Processing...
</>
) : (
'Batch Override'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -125,11 +125,11 @@ const chartConfig = {
},
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
color: 'var(--chart-1)',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
color: 'var(--chart-2)',
},
} satisfies ChartConfig;

View File

@@ -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 { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Key,
Copy,
Check,
Loader2,
RotateCcw,
Plus,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { KeyOptions } from './key-options';
interface KeyConfig {
id: string;
count: number;
balanceLimit: string;
balanceLimitReset: string;
validityDate: string;
}
interface ChildKeyCreatorProps {
baseUrl?: string;
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'>
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'>
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'>
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>
<AlertTitle>
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
Generated
</AlertTitle>
<AlertDescription>
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
You won&apos;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'>
Generated Keys ({newKeys.length})
</span>
{newKeys.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-7 text-[10px]'
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' />
) : (
<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'>
Bulk Export (All Keys)
</Label>
<div className='relative'>
<Textarea
readOnly
value={newKeys.join('\n')}
rows={Math.min(newKeys.length, 6)}
className='bg-muted/30 font-mono text-[10px] leading-relaxed'
/>
<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'>
Child API Key
</Label>
<Input
value={childKeyToCheck}
onChange={(e) => setChildKeyToCheck(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
</div>
<Button
onClick={handleCheckKey}
disabled={checking || !childKeyToCheck}
variant='outline'
className='w-full'
>
{checking ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Checking...
</>
) : (
<>
<RotateCcw className='mr-2 h-4 w-4' />
Check Status
</>
)}
</Button>
{keyStatus && (
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Total Spent:</span>
<span className='font-mono font-medium'>
{keyStatus.total_spent} mSats
</span>
</div>
{keyStatus.balance_limit !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Limit:</span>
<span className='font-mono font-medium'>
{keyStatus.balance_limit} mSats
</span>
</div>
)}
{keyStatus.validity_date !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Expires:</span>
<span className='font-mono font-medium'>
{new Date(
keyStatus.validity_date * 1000
).toLocaleDateString()}
</span>
</div>
)}
<div className='flex gap-2 pt-2'>
{keyStatus.is_drained && (
<Badge variant='destructive'>Drained</Badge>
)}
{keyStatus.is_expired && (
<Badge variant='destructive'>Expired</Badge>
)}
{!keyStatus.is_drained && !keyStatus.is_expired && (
<Badge>Active</Badge>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -218,11 +218,11 @@ export function CollectModelsDialog({
{!isLoadingModels && selectedProvider && remoteModels.length > 0 && (
<>
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-sm font-medium'>
{remoteModels.length} models available
</div>
<div className='flex gap-2'>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { type Model } from '@/lib/api/schemas/models';
import { CostCalculator } from '@/components/CostCalculator';
import { CostCalculator } from '@/components/cost-calculator';
import {
Dialog,
DialogContent,

View File

@@ -5,8 +5,9 @@ import { type Model } from '@/lib/api/schemas/models';
import {
calculateRequestCost,
estimateMinimumTokensForCost,
formatCost,
} from '@/lib/services/costValidation';
} from '@/lib/services/cost-validation';
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -21,6 +22,7 @@ interface CostCalculatorProps {
}
export function CostCalculator({ model }: CostCalculatorProps) {
const { displayUnit, usdPerSat } = useDisplayCurrency();
const [inputTokens, setInputTokens] = useState<number>(100);
const [outputTokens, setOutputTokens] = useState<number>(100);
@@ -39,6 +41,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
}, [model]);
const hasMinimumCost = model.min_cost_per_request > 0;
const formatDisplayCost = (amountUsd: number) =>
formatUsdAmountForDisplayUnit(amountUsd, displayUnit, usdPerSat);
return (
<div className='space-y-6'>
@@ -75,33 +79,33 @@ export function CostCalculator({ model }: CostCalculatorProps) {
<div className='flex justify-between'>
<span>Input Cost ({inputTokens.toLocaleString()} tokens):</span>
<span className='font-mono'>
{formatCost(costCalculation.inputCost)}
{formatDisplayCost(costCalculation.inputCost)}
</span>
</div>
<div className='flex justify-between'>
<span>Output Cost ({outputTokens.toLocaleString()} tokens):</span>
<span className='font-mono'>
{formatCost(costCalculation.outputCost)}
{formatDisplayCost(costCalculation.outputCost)}
</span>
</div>
<hr className='my-2' />
<div className='flex justify-between'>
<span>Base Cost:</span>
<span className='font-mono'>
{formatCost(costCalculation.baseCost)}
{formatDisplayCost(costCalculation.baseCost)}
</span>
</div>
<div className='flex justify-between'>
<span>Minimum Cost per Request:</span>
<span className='font-mono'>
{formatCost(costCalculation.minCostPerRequest)}
{formatDisplayCost(costCalculation.minCostPerRequest)}
</span>
</div>
<hr className='my-2' />
<div className='flex justify-between font-medium'>
<span>Final Cost:</span>
<span className='font-mono text-lg'>
{formatCost(costCalculation.finalCost)}
{formatDisplayCost(costCalculation.finalCost)}
</span>
</div>
</div>
@@ -110,16 +114,12 @@ export function CostCalculator({ model }: CostCalculatorProps) {
{/* Minimum Cost Alert */}
{hasMinimumCost && (
<Alert
className={
costCalculation.isMinimumApplied
? 'border-amber-200 bg-amber-50'
: 'border-green-200 bg-green-50'
}
variant={costCalculation.isMinimumApplied ? 'destructive' : 'default'}
>
{costCalculation.isMinimumApplied ? (
<AlertTriangle className='h-4 w-4 text-amber-600' />
<AlertTriangle className='h-4 w-4' />
) : (
<CheckCircle className='h-4 w-4 text-green-600' />
<CheckCircle className='h-4 w-4' />
)}
<AlertTitle>
{costCalculation.isMinimumApplied
@@ -128,8 +128,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
</AlertTitle>
<AlertDescription>
{costCalculation.isMinimumApplied
? `The calculated cost (${formatCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatCost(costCalculation.minCostPerRequest)} is applied.`
: `The calculated cost (${formatCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatCost(costCalculation.minCostPerRequest)}.`}
? `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatDisplayCost(costCalculation.minCostPerRequest)} is applied.`
: `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatDisplayCost(costCalculation.minCostPerRequest)}.`}
</AlertDescription>
</Alert>
)}
@@ -207,16 +207,20 @@ export function CostCalculator({ model }: CostCalculatorProps) {
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Input cost per 1M tokens:</span>
<span className='font-mono'>{formatCost(model.input_cost)}</span>
<span className='font-mono'>
{formatDisplayCost(model.input_cost)}
</span>
</div>
<div className='flex justify-between'>
<span>Output cost per 1M tokens:</span>
<span className='font-mono'>{formatCost(model.output_cost)}</span>
<span className='font-mono'>
{formatDisplayCost(model.output_cost)}
</span>
</div>
<div className='flex justify-between'>
<span>Minimum cost per request:</span>
<span className='font-mono'>
{formatCost(model.min_cost_per_request)}
{formatDisplayCost(model.min_cost_per_request)}
</span>
</div>
</div>

View File

@@ -1,20 +1,45 @@
'use client';
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronsUpDownIcon, CoinsIcon } from 'lucide-react';
import { useCurrencyStore } from '@/lib/stores/currency';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import type { DisplayUnit } from '@/lib/types/units';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Coins } from 'lucide-react';
import { cn } from '@/lib/utils';
export function CurrencyToggle() {
interface CurrencyToggleProps {
className?: string;
compact?: boolean;
menuSide?: 'top' | 'right' | 'bottom' | 'left';
menuAlign?: 'start' | 'center' | 'end';
}
const UNIT_OPTIONS: Array<{ value: DisplayUnit; label: string }> = [
{ value: 'msat', label: 'mSAT' },
{ value: 'sat', label: 'sat' },
{ value: 'usd', label: 'USD' },
];
function getLabel(unit: DisplayUnit): string {
const option = UNIT_OPTIONS.find((item) => item.value === unit);
return option?.label ?? unit;
}
export function CurrencyToggle({
className,
compact = false,
menuSide = 'bottom',
menuAlign = 'end',
}: CurrencyToggleProps) {
const { displayUnit, setDisplayUnit } = useCurrencyStore();
const { data: btcUsdPrice } = useQuery({
@@ -32,47 +57,55 @@ export function CurrencyToggle() {
}
}, [displayUnit, usdPerSat, setDisplayUnit]);
const getLabel = (unit: DisplayUnit) => {
switch (unit) {
case 'msat':
return 'mSAT';
case 'sat':
return 'sat';
case 'usd':
return 'USD';
default:
return unit;
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
variant='outline'
size='sm'
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
className={cn(
'border-border/60 bg-background/65 text-muted-foreground hover:text-foreground rounded-md',
compact
? 'h-8 w-10 justify-center px-0'
: 'h-8 justify-between gap-2',
className
)}
>
<Coins className='h-4 w-4' />
<span className='hidden sm:inline-block'>
{getLabel(displayUnit)}
<span className='inline-flex min-w-0 items-center gap-1.5'>
<CoinsIcon className='h-3.5 w-3.5 shrink-0' />
{compact ? null : (
<span className='truncate text-[11px] font-medium uppercase'>
{getLabel(displayUnit)}
</span>
)}
</span>
<span className='uppercase sm:hidden'>{displayUnit}</span>
{compact ? (
<span className='sr-only'>Currency: {getLabel(displayUnit)}</span>
) : (
<ChevronsUpDownIcon className='h-3.5 w-3.5 opacity-70' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
Millisatoshis (mSAT)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
Satoshis (sat)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDisplayUnit('usd')}
disabled={!usdPerSat}
<DropdownMenuContent side={menuSide} align={menuAlign}>
<DropdownMenuRadioGroup
value={displayUnit}
onValueChange={(value) => {
if (value !== 'msat' && value !== 'sat' && value !== 'usd') return;
if (value === 'usd' && !usdPerSat) return;
setDisplayUnit(value);
}}
>
US Dollar (USD)
</DropdownMenuItem>
{UNIT_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
disabled={option.value === 'usd' && !usdPerSat}
className='uppercase'
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -21,7 +21,8 @@ export function DashboardBalanceSummary({
queryFn: async () => {
return WalletService.getDetailedBalances();
},
refetchInterval: 30000,
refetchInterval: 900000,
staleTime: 300000,
});
const calculateTotals = (balances: BalanceDetail[]) => {
@@ -59,37 +60,38 @@ export function DashboardBalanceSummary({
title: 'Your Balance',
value: formatAmount(totals.totalOwner),
icon: Coins,
color: 'text-green-600',
bgColor: 'bg-green-100 dark:bg-green-900/20',
color: 'text-green-600 dark:text-green-300',
},
{
title: 'Total Wallet',
value: formatAmount(totals.totalWallet),
icon: Wallet,
color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
color: 'text-blue-600 dark:text-blue-300',
},
{
title: 'User Balance',
value: formatAmount(totals.totalUser),
icon: User,
color: 'text-purple-600',
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
color: 'text-purple-600 dark:text-purple-300',
},
];
return (
<div className='grid gap-4 md:grid-cols-3'>
<div className='grid grid-cols-2 gap-2.5 max-[359px]:grid-cols-1 sm:gap-3 lg:grid-cols-3'>
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
<div className={`rounded-full p-2 ${card.bgColor}`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
<Card key={card.title} size='sm'>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-1'>
<CardTitle className='text-muted-foreground text-[11px] font-medium sm:text-sm'>
{card.title}
</CardTitle>
<span className='inline-flex size-6 items-center justify-center sm:size-7'>
<card.icon className={`size-3.5 sm:size-4 ${card.color}`} />
</span>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{card.value}</div>
<CardContent className='pt-0'>
<div className='text-base font-semibold break-words tabular-nums sm:text-xl'>
{card.value}
</div>
</CardContent>
</Card>
))}

View File

@@ -190,11 +190,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
variant='outline'
className='text-muted-foreground flex gap-1 px-1.5 [&_svg]:size-3'
>
{row.original.status === 'Done' ? (
<CheckCircle2Icon className='text-green-500 dark:text-green-400' />
) : (
<LoaderIcon />
)}
{row.original.status === 'Done' ? <CheckCircle2Icon /> : <LoaderIcon />}
{row.original.status}
</Badge>
),

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, RefreshCw, AlertCircle, Wallet } from 'lucide-react';
import { RefreshCw, AlertCircle, Wallet, User, Coins } from 'lucide-react';
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
import {
Card,
@@ -12,6 +12,23 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { WithdrawModal } from '@/components/withdraw-modal';
import { cn } from '@/lib/utils';
import type { DisplayUnit } from '@/lib/types/units';
@@ -66,18 +83,46 @@ export function DetailedWalletBalance({
? calculateTotals(data)
: { totalWallet: 0, totalUser: 0, totalOwner: 0 };
const rows = (data ?? [])
.filter(
(detail) =>
(detail.wallet_balance && detail.wallet_balance > 0) || detail.error
)
.map((detail, index) => {
const walletMsat = convertToMsat(detail.wallet_balance || 0, detail.unit);
const userMsat = convertToMsat(detail.user_balance || 0, detail.unit);
const ownerMsat = convertToMsat(detail.owner_balance || 0, detail.unit);
return {
key: `${detail.mint_url}-${detail.unit}-${index}`,
detail,
walletMsat,
userMsat,
ownerMsat,
};
});
const formatMintLabel = (detail: BalanceDetail) =>
`${detail.mint_url.replace('https://', '').replace('http://', '')}${detail.unit.toUpperCase()}`;
return (
<>
<Card className='h-full w-full shadow-sm'>
<Card>
<CardHeader className='pb-4'>
<div className='flex items-center justify-between'>
<CardTitle className='text-xl'>Cashu Wallet Balance</CardTitle>
<div className='flex gap-2'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1.5'>
<CardTitle>Cashu Wallet Balance</CardTitle>
<CardDescription>
Detailed balance breakdown by mint and currency
</CardDescription>
</div>
<div className='flex w-full gap-2 sm:w-auto'>
<Button
variant='default'
size='sm'
onClick={() => setWithdrawModalOpen(true)}
disabled={isLoading || !data || data.length === 0}
className='flex-1 sm:flex-none'
>
<Wallet className='mr-2 h-4 w-4' />
Withdraw
@@ -87,7 +132,6 @@ export function DetailedWalletBalance({
size='icon'
onClick={() => refetch()}
disabled={isLoading || isFetching}
className='h-8 w-8'
>
<RefreshCw
className={cn(
@@ -99,177 +143,218 @@ export function DetailedWalletBalance({
</Button>
</div>
</div>
<CardDescription>
Detailed balance breakdown by mint and currency
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-primary h-8 w-8 animate-spin' />
<div className='space-y-4'>
<div className='grid gap-3 md:grid-cols-3'>
{Array.from({ length: 3 }).map((_, index) => (
<Card key={`wallet-stat-skeleton-${index}`}>
<CardHeader className='space-y-2 pb-1'>
<Skeleton className='h-3.5 w-28' />
<Skeleton className='h-3 w-8' />
</CardHeader>
<CardContent className='pt-0'>
<Skeleton className='h-7 w-24' />
</CardContent>
</Card>
))}
</div>
<Skeleton className='h-3 w-52' />
<div className='space-y-2'>
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton
key={`wallet-row-skeleton-${index}`}
className='h-11 w-full'
/>
))}
</div>
</div>
) : isError ? (
<div className='bg-destructive/10 text-destructive flex items-center space-x-2 rounded-md p-4'>
<Alert variant='destructive'>
<AlertCircle className='h-5 w-5' />
<span>Error loading balance: {(error as Error).message}</span>
</div>
<AlertDescription>
Error loading balance: {(error as Error).message}
</AlertDescription>
</Alert>
) : (
<div className='space-y-6'>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Your Balance (Total)
</span>
<span className='text-2xl font-bold text-green-600'>
{formatAmount(totals.totalOwner)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Wallet
</span>
<span className='text-lg font-semibold'>
{formatAmount(totals.totalWallet)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
User Balance
</span>
<span className='text-lg font-semibold'>
{formatAmount(totals.totalUser)}
</span>
</div>
<div className='grid gap-3 md:grid-cols-3'>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Your Balance (Total)
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Coins className='size-4 text-green-600 dark:text-green-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-primary text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalOwner)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Wallet
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Wallet className='size-4 text-blue-600 dark:text-blue-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalWallet)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
User Balance
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<User className='size-4 text-purple-600 dark:text-purple-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalUser)}
</p>
</CardContent>
</Card>
</div>
<p className='text-muted-foreground text-xs'>
Your balance = Total wallet - User balance
</p>
<div className='overflow-hidden rounded-lg border'>
{/* Desktop Table Header */}
<div className='bg-muted hidden grid-cols-4 gap-2 p-3 text-sm font-semibold md:grid'>
<div>Mint / Unit</div>
<div className='text-right'>Wallet</div>
<div className='text-right'>Users</div>
<div className='text-right'>Owner</div>
</div>
{data && data.length > 0 ? (
data
.filter(
(detail) =>
(detail.wallet_balance && detail.wallet_balance > 0) ||
detail.error
)
.map((detail, index) => {
const walletMsat = convertToMsat(
detail.wallet_balance || 0,
detail.unit
);
const userMsat = convertToMsat(
detail.user_balance || 0,
detail.unit
);
const ownerMsat = convertToMsat(
detail.owner_balance || 0,
detail.unit
);
return (
<div
key={index}
className={cn(
'border-t p-3 text-sm',
detail.error && 'bg-destructive/10 text-destructive'
)}
>
{/* Desktop Layout */}
<div className='hidden grid-cols-4 gap-2 md:grid'>
<div className='text-xs break-all'>
{detail.mint_url
.replace('https://', '')
.replace('http://', '')}{' '}
{detail.unit.toUpperCase()}
</div>
<div className='text-right font-mono'>
{detail.error
? 'error'
: formatAmount(walletMsat)}
</div>
<div className='text-right font-mono'>
{detail.error ? '-' : formatAmount(userMsat)}
</div>
<div
{rows.length > 0 ? (
<>
<div className='hidden md:block'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Mint / Unit</TableHead>
<TableHead className='text-right'>Wallet</TableHead>
<TableHead className='text-right'>Users</TableHead>
<TableHead className='text-right'>Owner</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(
({
key,
detail,
walletMsat,
userMsat,
ownerMsat,
}) => (
<TableRow
key={key}
className={cn(
'text-right font-mono',
!detail.error &&
ownerMsat > 0 &&
'font-semibold text-green-600'
detail.error &&
'bg-destructive/10 text-destructive'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</div>
</div>
{/* Mobile Layout */}
<div className='space-y-3 md:hidden'>
<div className='space-y-1'>
<span className='text-muted-foreground text-xs font-medium'>
Mint / Unit
</span>
<div className='font-mono text-xs break-all'>
{detail.mint_url
.replace('https://', '')
.replace('http://', '')}{' '}
{detail.unit.toUpperCase()}
</div>
</div>
<div className='grid grid-cols-3 gap-2'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Wallet
</div>
<div className='truncate font-mono text-sm'>
{detail.error
? 'error'
: formatAmount(walletMsat)}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Users
</div>
<div className='truncate font-mono text-sm'>
{detail.error ? '-' : formatAmount(userMsat)}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Owner
</div>
<div
className={cn(
'truncate font-mono text-sm',
!detail.error &&
ownerMsat > 0 &&
'font-semibold text-green-600'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</div>
</div>
</div>
</div>
</div>
);
})
) : (
<div className='text-muted-foreground p-4 text-center text-sm'>
No balances to display
<TableCell className='max-w-md font-mono text-xs break-all whitespace-normal'>
{formatMintLabel(detail)}
</TableCell>
<TableCell className='text-right font-mono'>
{detail.error
? 'error'
: formatAmount(walletMsat)}
</TableCell>
<TableCell className='text-right font-mono'>
{detail.error ? '-' : formatAmount(userMsat)}
</TableCell>
<TableCell
className={cn(
'text-right font-mono',
!detail.error &&
ownerMsat > 0 &&
'text-primary font-semibold'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
)}
</div>
<div className='space-y-2 md:hidden'>
{rows.map(
({ key, detail, walletMsat, userMsat, ownerMsat }) => (
<Card
key={`${key}-mobile`}
className={cn(
detail.error &&
'border-destructive/40 bg-destructive/5'
)}
>
<CardHeader className='p-4 pb-2'>
<CardDescription className='font-mono text-xs break-all'>
{formatMintLabel(detail)}
</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-2 p-4 pt-0 sm:grid-cols-3 sm:gap-3'>
<div>
<p className='text-muted-foreground text-xs'>
Wallet
</p>
<p className='font-mono text-sm'>
{detail.error
? 'error'
: formatAmount(walletMsat)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Users
</p>
<p className='font-mono text-sm'>
{detail.error ? '-' : formatAmount(userMsat)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Owner
</p>
<p
className={cn(
'font-mono text-sm',
!detail.error &&
ownerMsat > 0 &&
'text-primary font-semibold'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</p>
</div>
</CardContent>
</Card>
)
)}
</div>
</>
) : (
<Empty className='py-6'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Wallet className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No balances to display</EmptyTitle>
<EmptyDescription>
Wallet balances will appear here after funds are
available.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
)}
</CardContent>

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Copy, Check, SendIcon } from 'lucide-react';
import { Loader2, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { QRCodeSVG } from 'qrcode.react';
@@ -101,10 +101,7 @@ export function EcashRedeem() {
return (
<Card className='h-full w-full shadow-sm'>
<CardHeader>
<div className='flex items-center space-x-2'>
<SendIcon className='text-primary h-5 w-5' />
<CardTitle>Send eCash</CardTitle>
</div>
<CardTitle>Send eCash</CardTitle>
<CardDescription>
Generate a token to send eCash to someone
</CardDescription>

View File

@@ -27,6 +27,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Users, Key, Loader2, Globe, AlertTriangle, Info } from 'lucide-react';
import { toast } from 'sonner';
import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -122,9 +123,9 @@ export function EditGroupForm({
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Models using group API key:</span>
<span className='font-medium text-blue-600'>
<Badge variant='secondary' className='tabular-nums'>
{modelsWithoutKeys}
</span>
</Badge>
</div>
</div>
</div>
@@ -138,18 +139,18 @@ export function EditGroupForm({
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Models using group URL:</span>
<span className='font-medium text-blue-600'>
<Badge variant='secondary' className='tabular-nums'>
{modelsUsingGroupUrl}
</span>
</Badge>
</div>
<div className='flex justify-between'>
<span>Models with individual URLs:</span>
<span className='font-medium text-green-600'>
<Badge variant='outline' className='tabular-nums'>
{models.length - modelsUsingGroupUrl}
</span>
</Badge>
</div>
{groupSettings?.group_url && (
<div className='mt-2 rounded bg-blue-50 p-2 text-xs'>
<div className='bg-muted mt-2 rounded p-2 text-xs'>
<span className='font-medium'>Current group URL:</span>{' '}
{groupSettings.group_url}
</div>
@@ -163,26 +164,22 @@ export function EditGroupForm({
<div className='max-h-32 overflow-y-auto'>
<div className='flex flex-wrap gap-2'>
{models.map((model) => (
<span
<Badge
key={model.id}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
model.api_key
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}
variant={model.api_key ? 'default' : 'secondary'}
className='max-w-full'
title={
model.api_key
? 'Has individual API key and URL'
: 'Uses group API key and URL'
}
>
{model.name}
{model.api_key && ' 🔑'}
<span className='truncate'>{model.name}</span>
{model.api_key && <Key className='ml-1 h-3 w-3' />}
{model.url &&
!model.url.startsWith('/') &&
model.api_key &&
' 🌐'}
</span>
model.api_key && <Globe className='ml-1 h-3 w-3' />}
</Badge>
))}
</div>
</div>
@@ -310,28 +307,30 @@ export function EditGroupForm({
)}
/>
<div className='rounded-md border border-amber-200 bg-amber-50 p-4'>
<p className='text-sm text-amber-800'>
<strong>How group settings work:</strong>
</p>
<ul className='mt-2 list-inside list-disc space-y-1 text-sm text-amber-800'>
<li>
Models with individual API keys and URLs will keep their
specific settings
</li>
<li>
Models without individual settings will use the group defaults
</li>
<li>
Removing the group URL makes models fall back to the system
default endpoint
</li>
<li>
You can use &quot;Apply Group Settings&quot; to force models
to use group configurations
</li>
</ul>
</div>
<Alert>
<Info className='h-4 w-4' />
<AlertDescription>
<p className='font-medium'>How group settings work:</p>
<ul className='mt-2 list-inside list-disc space-y-1 text-sm'>
<li>
Models with individual API keys and URLs will keep their
specific settings
</li>
<li>
Models without individual settings will use the group
defaults
</li>
<li>
Removing the group URL makes models fall back to the system
default endpoint
</li>
<li>
You can use &quot;Apply Group Settings&quot; to force models
to use group configurations
</li>
</ul>
</AlertDescription>
</Alert>
<div className='flex justify-end gap-2 pt-4'>
<Button

View File

@@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { type Model } from '@/lib/api/schemas/models';
import { AdminService } from '@/lib/api/services/admin';
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -32,9 +32,9 @@ import { Switch } from '@/components/ui/switch';
const EditModelFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
context_length: z.coerce.number().min(0),
prompt: z.coerce.number().min(0),
completion: z.coerce.number().min(0),
context_length: z.number().min(0),
prompt: z.number().min(0),
completion: z.number().min(0),
enabled: z.boolean(),
});
@@ -47,6 +47,29 @@ const roundToFiveDecimals = (value: number | undefined | null): number => {
return Math.round(value * 100000) / 100000;
};
const toNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const toStringArray = (value: unknown, fallback: string[]): string[] => {
if (!Array.isArray(value)) {
return fallback;
}
const filtered = value.filter(
(item): item is string => typeof item === 'string'
);
return filtered.length > 0 ? filtered : fallback;
};
interface EditModelFormProps {
model: Model;
providerId?: number;
@@ -82,6 +105,81 @@ interface AdminModelData {
enabled: boolean;
}
const normalizeAdminModelData = (
adminModel: AdminModel,
fallbackModel: Model,
providerId: number
): AdminModelData => {
const pricingRecord =
adminModel.pricing && typeof adminModel.pricing === 'object'
? (adminModel.pricing as Record<string, unknown>)
: {};
const architectureRecord =
adminModel.architecture && typeof adminModel.architecture === 'object'
? (adminModel.architecture as Record<string, unknown>)
: {};
return {
id: adminModel.id,
name: adminModel.name,
description: adminModel.description || '',
created: toNumber(adminModel.created, Math.floor(Date.now() / 1000)),
context_length: Math.max(
0,
Math.trunc(
toNumber(adminModel.context_length, fallbackModel.contextLength || 4096)
)
),
architecture: {
modality:
typeof architectureRecord.modality === 'string'
? architectureRecord.modality
: fallbackModel.modelType || 'text',
input_modalities: toStringArray(architectureRecord.input_modalities, [
fallbackModel.modelType || 'text',
]),
output_modalities: toStringArray(architectureRecord.output_modalities, [
fallbackModel.modelType || 'text',
]),
tokenizer:
typeof architectureRecord.tokenizer === 'string'
? architectureRecord.tokenizer
: '',
instruct_type:
typeof architectureRecord.instruct_type === 'string'
? architectureRecord.instruct_type
: null,
},
pricing: {
prompt: roundToFiveDecimals(
toNumber(pricingRecord.prompt, fallbackModel.input_cost)
),
completion: roundToFiveDecimals(
toNumber(pricingRecord.completion, fallbackModel.output_cost)
),
request: toNumber(pricingRecord.request, 0),
image: toNumber(pricingRecord.image, 0),
web_search: toNumber(pricingRecord.web_search, 0),
internal_reasoning: toNumber(pricingRecord.internal_reasoning, 0),
},
per_request_limits:
adminModel.per_request_limits === null ||
adminModel.per_request_limits === undefined
? adminModel.per_request_limits
: null,
top_provider:
adminModel.top_provider === null || adminModel.top_provider === undefined
? adminModel.top_provider
: null,
upstream_provider_id:
typeof adminModel.upstream_provider_id === 'number'
? adminModel.upstream_provider_id
: providerId,
enabled: adminModel.enabled !== false,
};
};
export function EditModelForm({
model,
providerId,
@@ -114,29 +212,28 @@ export function EditModelForm({
}
try {
console.log('Loading admin model:', {
providerId,
modelId: model.full_name,
});
const adminModel = await AdminService.getProviderModel(
providerId,
model.id
);
setAdminModelData(adminModel as AdminModelData);
const normalizedAdminModel = normalizeAdminModelData(
adminModel,
model,
providerId
);
setAdminModelData(normalizedAdminModel);
setIsNewOverride(false);
form.reset({
name: adminModel.name,
description: adminModel.description || '',
context_length: adminModel.context_length,
prompt: roundToFiveDecimals(adminModel.pricing.prompt),
completion: roundToFiveDecimals(adminModel.pricing.completion),
enabled: adminModel.enabled !== false,
name: normalizedAdminModel.name,
description: normalizedAdminModel.description || '',
context_length: normalizedAdminModel.context_length,
prompt: normalizedAdminModel.pricing.prompt,
completion: normalizedAdminModel.pricing.completion,
enabled: normalizedAdminModel.enabled !== false,
});
} catch (error: unknown) {
console.log('Model not in database, will create new override:', error);
} catch {
setIsNewOverride(true);
setAdminModelData({
id: model.full_name,
@@ -238,11 +335,9 @@ export function EditModelForm({
};
if (isNewOverride) {
console.log('Creating new model override');
await AdminService.createProviderModel(providerId, payload);
toast.success('Model override created successfully!');
} else {
console.log('Updating existing model override');
await AdminService.updateProviderModel(
providerId,
adminModelData.id,
@@ -318,7 +413,19 @@ export function EditModelForm({
type='number'
min='0'
placeholder='4096'
{...field}
value={field.value ?? ''}
onChange={(e) => {
const value = e.target.value;
field.onChange(
value === '' ? 0 : parseInt(value, 10) || 0
);
}}
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
field.onChange(
Number.isNaN(value) ? 0 : Math.max(0, value)
);
}}
className='w-full'
/>
</FormControl>

View File

@@ -38,8 +38,8 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
<CardTitle>Recent Errors ({errors.length})</CardTitle>
</CardHeader>
<CardContent>
<div className='max-h-[400px] overflow-y-auto'>
<Table>
<div className='max-h-[420px] max-w-full overflow-y-auto'>
<Table className='min-w-[640px] sm:min-w-[760px]'>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>

View File

@@ -0,0 +1,89 @@
import { Zap, Calendar, Shield } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface KeyOptionsProps {
balanceLimit: string;
setBalanceLimit: (val: string) => void;
validityDate: string;
setValidityDate: (val: string) => void;
balanceLimitReset: string;
setBalanceLimitReset: (val: string) => void;
showBalanceLimit?: boolean;
}
export function KeyOptions({
balanceLimit,
setBalanceLimit,
validityDate,
setValidityDate,
balanceLimitReset,
setBalanceLimitReset,
showBalanceLimit = true,
}: KeyOptionsProps) {
return (
<div className='grid gap-4 sm:grid-cols-3'>
{showBalanceLimit && (
<div className='space-y-2'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Zap className='h-3 w-3' />
Balance Limit (mSats)
</Label>
<Input
type='number'
placeholder='No limit'
value={balanceLimit}
onChange={(e) => setBalanceLimit(e.target.value)}
className='h-9 text-xs'
name='balance_limit_msats'
autoComplete='off'
/>
</div>
)}
<div className='space-y-2'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Calendar className='h-3 w-3' />
Validity Date
</Label>
<Input
type='date'
value={validityDate}
onChange={(e) => setValidityDate(e.target.value)}
className='h-9 text-xs'
name='validity_date'
/>
</div>
<div className='space-y-2'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Shield className='h-3 w-3' />
Reset Policy
</Label>
<Select
value={balanceLimitReset || 'none'}
onValueChange={(value) =>
setBalanceLimitReset(value === 'none' ? '' : value)
}
>
<SelectTrigger className='h-9 w-full text-xs'>
<SelectValue placeholder='None' />
</SelectTrigger>
<SelectContent>
<SelectItem value='none'>None</SelectItem>
<SelectItem value='daily'>Daily</SelectItem>
<SelectItem value='weekly'>Weekly</SelectItem>
<SelectItem value='monthly'>Monthly</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -7,19 +7,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
msats?: string;
};
import { WalletBalanceStats } from './wallet-balance-stats';
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
import type { RefundReceipt } from './cashu-payment-workflow';
interface ApiKeyManagerProps {
baseUrl: string;
@@ -51,19 +41,31 @@ async function fetchWalletInfo(
api_key: string;
balance: number;
reserved?: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
child_keys?: ChildKeyInfo[];
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
@@ -170,17 +172,14 @@ export function ApiKeyManager({
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<RefreshCcw className='text-primary h-5 w-5' />
API Key Management
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-xl'>API Key Management</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
Manage your existing API keys and balances
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Manage existing key</span>
{walletInfo && (
<span className='text-primary'>
@@ -221,43 +220,15 @@ export function ApiKeyManager({
{showManageDetails && (
<div className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
<WalletBalanceStats
balanceMsats={walletInfo?.balanceMsats}
reservedMsats={walletInfo?.reservedMsats}
/>
<Separator />
<div className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Refund remaining balance</span>
</header>
<div className='flex flex-wrap gap-2'>

Some files were not shown because too many files have changed in this diff Show More