Compare commits

...

56 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
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
192 changed files with 21279 additions and 12876 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*

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.

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

@@ -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,7 +24,24 @@ 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:
@@ -32,18 +49,26 @@ You can run the pre-built image directly:
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 --name routstr routstr-local
docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local
```
Verify it's running:
@@ -54,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

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

View File

@@ -118,6 +118,13 @@ def create_model_mappings(
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
seen_model_provider: set[tuple[str, str]] = set()
providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {}
for upstream in upstreams:
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
providers_by_db_id[db_id] = upstream
# Separate OpenRouter from other providers
openrouter: "BaseUpstreamProvider" | None = None
@@ -134,6 +141,16 @@ def create_model_mappings(
"""Get base model ID by removing provider prefix."""
return model_id.split("/", 1)[1] if "/" in model_id else model_id
def get_provider_identity(upstream: "BaseUpstreamProvider") -> str:
"""Get a stable provider identity used for deduplication."""
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
return f"db:{db_id}"
provider_type = str(getattr(upstream, "provider_type", "") or "").lower()
base_url = str(getattr(upstream, "base_url", "") or "").lower()
return f"{provider_type}|{base_url}"
def _add_candidate(
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
@@ -148,6 +165,7 @@ def create_model_mappings(
) -> None:
"""Process all models from a given provider."""
upstream_prefix = getattr(upstream, "upstream_name", None)
provider_key = get_provider_identity(upstream)
for model in upstream.get_cached_models():
if not model.enabled or model.id in disabled_model_ids:
@@ -189,6 +207,7 @@ def create_model_mappings(
# Try to set each alias
for alias in aliases:
_add_candidate(alias, model_to_use, upstream)
seen_model_provider.add((model_to_use.id.lower(), provider_key))
# Process non-OpenRouter providers first
for upstream in other_upstreams:
@@ -198,6 +217,85 @@ def create_model_mappings(
if openrouter:
process_provider_models(openrouter, is_openrouter=True)
# Include enabled DB overrides even when provider discovery misses models.
# This is important for deployment-based providers like Azure.
for model_id, override_data in overrides_by_id.items():
if model_id in disabled_model_ids:
continue
override_row, provider_fee = override_data
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
if not isinstance(upstream_provider_id, int):
continue
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
if upstream_for_override is None:
continue
provider_key = get_provider_identity(upstream_for_override)
dedupe_key = (model_id.lower(), provider_key)
if dedupe_key in seen_model_provider:
continue
try:
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
except Exception as exc:
logger.warning(
"Skipping invalid model override while building model mappings",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
if not model_to_use.enabled:
continue
base_id = get_base_model_id(model_to_use.id)
is_openrouter = (
getattr(upstream_for_override, "base_url", "")
== "https://openrouter.ai/api/v1"
)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream_for_override.provider_type,
}
)
unique_models[base_id] = unique_model
try:
aliases = resolve_model_alias(
model_to_use.id,
model_to_use.canonical_slug,
alias_ids=model_to_use.alias_ids,
)
except Exception as exc:
logger.warning(
"Skipping model aliases for invalid override model",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
upstream_prefix = getattr(upstream_for_override, "upstream_name", None)
if upstream_prefix and "/" not in model_to_use.id:
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
if prefixed_id not in aliases:
aliases.append(prefixed_id)
for alias in aliases:
_add_candidate(alias, model_to_use, upstream_for_override)
seen_model_provider.add(dedupe_key)
# Sort candidates and build final maps
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}

View File

@@ -348,6 +348,8 @@ async def validate_bearer_key(
)
return new_key
except HTTPException:
raise
except Exception as e:
logger.error(
"Cashu token redemption failed",
@@ -588,12 +590,15 @@ async def pay_for_request(
async def revert_pay_for_request(
key: ApiKey, session: AsyncSession, cost_per_request: int
) -> None:
) -> bool:
"""Revert a previously reserved payment. Returns True if revert succeeded,
False if the reservation was already released (prevents negative reserved_balance)."""
billing_key = await get_billing_key(key, session)
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
total_requests=col(ApiKey.total_requests) - 1,
@@ -607,6 +612,7 @@ async def revert_pay_for_request(
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,
@@ -616,8 +622,8 @@ async def revert_pay_for_request(
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] + "...",
@@ -625,19 +631,11 @@ async def revert_pay_for_request(
"current_reserved_balance": billing_key.reserved_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
return False
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
return True
async def adjust_payment_for_tokens(
@@ -669,17 +667,19 @@ async def adjust_payment_for_tokens(
release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
)
)
await session.exec(release_stmt) # type: ignore[call-overload]
result = await session.exec(release_stmt) # type: ignore[call-overload]
# Also release on child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost
@@ -688,14 +688,24 @@ async def adjust_payment_for_tokens(
await session.exec(child_release_stmt) # type: ignore[call-overload]
await session.commit()
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
if result.rowcount == 0: # type: ignore[union-attr]
logger.warning(
"Release reservation skipped - already released (no-op to prevent negative reserved_balance)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
else:
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
except Exception as e:
logger.error(
"Failed to release reservation in fallback",
@@ -766,6 +776,8 @@ async def adjust_payment_for_tokens(
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"new_balance": billing_key.balance,
"model": model,
},
@@ -789,6 +801,8 @@ async def adjust_payment_for_tokens(
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
},
)
@@ -997,18 +1011,8 @@ 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:

View File

@@ -6,8 +6,9 @@ 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
@@ -34,10 +35,8 @@ async def get_key_from_header(
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
return {
info = {
"api_key": "sk-" + key.hashed_key,
"balance": billing_key.balance,
"reserved": billing_key.reserved_balance,
@@ -50,6 +49,26 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
"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)
@@ -117,8 +136,6 @@ async def topup_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
if topup_request is not None:
@@ -188,8 +205,9 @@ async def refund_wallet_endpoint(
key: ApiKey = await validate_bearer_key(bearer_value, session)
if cached := await _refund_cache_get(bearer_value):
return cached
if key.total_balance <= 0:
if cached := await _refund_cache_get(bearer_value):
return cached
if key.parent_key_hash:
raise HTTPException(

View File

@@ -2,6 +2,7 @@ import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
@@ -26,18 +27,55 @@ logger = get_logger(__name__)
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
admin_sessions: dict[str, int] = {}
ADMIN_SESSION_DURATION = 3600
ADMIN_SESSION_DURATION = 12 * 60 * 60
# Usage analytics remain queryable up to 12 months.
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
def _current_timestamp() -> int:
return int(datetime.now(timezone.utc).timestamp())
def _cleanup_expired_admin_sessions(now_timestamp: int | None = None) -> None:
current_timestamp = (
now_timestamp if now_timestamp is not None else _current_timestamp()
)
expired_tokens = [
token
for token, expiry_timestamp in admin_sessions.items()
if expiry_timestamp <= current_timestamp
]
for token in expired_tokens:
admin_sessions.pop(token, None)
def _raise_unauthorized(detail: str) -> NoReturn:
raise HTTPException(
status_code=401,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
def require_admin_api(request: Request) -> None:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
expiry = admin_sessions.get(token)
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
return
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
_raise_unauthorized("Missing bearer token")
raise HTTPException(status_code=403, detail="Unauthorized")
token = auth_header.split(" ", 1)[1].strip()
if not token:
_raise_unauthorized("Missing bearer token")
now_timestamp = _current_timestamp()
expiry_timestamp = admin_sessions.get(token)
if expiry_timestamp is None:
_raise_unauthorized("Invalid session token")
if expiry_timestamp <= now_timestamp:
admin_sessions.pop(token, None)
_raise_unauthorized("Session expired")
_cleanup_expired_admin_sessions(now_timestamp)
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
@@ -206,18 +244,10 @@ async def admin_login(
raise HTTPException(status_code=401, detail="Invalid password")
token = secrets.token_urlsafe(32)
expiry_timestamp = (
int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION
)
expiry_timestamp = _current_timestamp() + ADMIN_SESSION_DURATION
admin_sessions[token] = expiry_timestamp
expired_tokens = [
t
for t, exp in admin_sessions.items()
if exp <= int(datetime.now(timezone.utc).timestamp())
]
for t in expired_tokens:
del admin_sessions[t]
_cleanup_expired_admin_sessions()
return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION}
@@ -456,18 +486,18 @@ async def batch_override_provider_models(
logger.info(
f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}"
)
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
overridden_count = 0
for model_data in payload.models:
# Try to get existing model regardless of whether it's enabled or not
existing_row = await session.get(ModelRow, (model_data.id, provider_id))
if existing_row:
# Update existing
existing_row.name = model_data.name
@@ -483,7 +513,9 @@ async def batch_override_provider_models(
else None
)
existing_row.top_provider = (
json.dumps(model_data.top_provider) if model_data.top_provider else None
json.dumps(model_data.top_provider)
if model_data.top_provider
else None
)
existing_row.canonical_slug = model_data.canonical_slug
existing_row.alias_ids = (
@@ -508,23 +540,32 @@ async def batch_override_provider_models(
else None
),
top_provider=(
json.dumps(model_data.top_provider) if model_data.top_provider else None
json.dumps(model_data.top_provider)
if model_data.top_provider
else None
),
canonical_slug=model_data.canonical_slug,
alias_ids=(
json.dumps(model_data.alias_ids) if model_data.alias_ids else None
json.dumps(model_data.alias_ids)
if model_data.alias_ids
else None
),
upstream_provider_id=provider_id,
enabled=model_data.enabled,
)
session.add(row)
overridden_count += 1
await session.commit()
await refresh_model_maps()
return {"ok": True, "count": overridden_count, "message": f"Successfully batch overridden {overridden_count} models"}
return {
"ok": True,
"count": overridden_count,
"message": f"Successfully batch overridden {overridden_count} models",
}
class UpstreamProviderCreate(BaseModel):
provider_type: str
@@ -570,12 +611,14 @@ async def create_upstream_provider(
async with create_session() as session:
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == payload.base_url
UpstreamProviderRow.base_url == payload.base_url,
UpstreamProviderRow.api_key == payload.api_key,
)
)
if result.first():
raise HTTPException(
status_code=409, detail="Provider with this base URL already exists"
status_code=409,
detail="Provider with this base URL and API key already exists",
)
provider = UpstreamProviderRow(
@@ -717,9 +760,7 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
)
db_model_ids = {model.id for model in db_models}
filtered_remote_models = [
m for m in upstream_models if m.name not in db_model_ids
]
filtered_remote_models = [m for m in upstream_models if m.id not in db_model_ids]
return {
"provider": {
@@ -930,18 +971,55 @@ async def get_usage_metrics(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
) -> dict:
"""Get usage metrics aggregated by time interval."""
return log_manager.get_usage_metrics(interval=interval, hours=hours)
@admin_router.get("/api/usage/dashboard", dependencies=[Depends(require_admin_api)])
async def get_usage_dashboard(
request: Request,
interval: int = Query(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
error_limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
),
model_limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
),
) -> dict:
"""
Get all dashboard analytics in one request.
This runs one combined aggregation pass and avoids repeated scans.
"""
return log_manager.get_usage_dashboard(
interval=interval,
hours=hours,
error_limit=error_limit,
model_limit=model_limit,
)
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
async def get_usage_summary(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
) -> dict:
"""Get summary statistics for the specified time period."""
@@ -952,7 +1030,10 @@ async def get_usage_summary(
async def get_error_details(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
@@ -968,7 +1049,10 @@ async def get_error_details(
async def get_revenue_by_model(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"

View File

@@ -1,4 +1,6 @@
import os
import pathlib
import sqlite3
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator
@@ -181,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"

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

@@ -31,9 +31,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.4.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.3.0"
__version__ = "0.4.0"
@asynccontextmanager

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,8 @@ class CostData(BaseModel):
output_msats: int
total_msats: int
total_usd: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
class MaxCostData(CostData):
@@ -63,10 +65,49 @@ async def calculate_cost( # todo: can be sync
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
@@ -104,6 +145,8 @@ async def calculate_cost( # todo: can be sync
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(
@@ -184,31 +227,10 @@ 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)
@@ -234,4 +256,6 @@ async def calculate_cost( # todo: can be sync
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

@@ -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", {})
@@ -156,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)

View File

@@ -300,9 +300,13 @@ async def proxy(
session,
model_obj,
)
except UpstreamError:
# Let the outer UpstreamError handler manage retry/revert
raise
except Exception as e:
# Unexpected error (not an upstream failure) — revert and propagate
logger.error(
"Upstream request failed, ensuring payment is reverted",
"Unexpected error in upstream request, reverting payment",
extra={
"error": str(e),
"error_type": type(e).__name__,
@@ -390,7 +394,8 @@ async def get_bearer_token_key(
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)

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,21 +5,18 @@ import json
import re
import traceback
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Mapping
from typing import Mapping
import httpx
from fastapi import BackgroundTasks, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from sqlmodel import select
from ..auth import adjust_payment_for_tokens, revert_pay_for_request
from ..auth import adjust_payment_for_tokens
from ..core import get_logger
from ..core.db import ApiKey, AsyncSession, create_session
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session
from ..core.exceptions import UpstreamError
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..payment.cost_calculation import (
CostData,
CostDataError,
@@ -32,6 +29,7 @@ from ..payment.models import (
Pricing,
_calculate_usd_max_costs,
_update_model_sats_pricing,
list_models,
)
from ..payment.price import sats_usd_price
from ..wallet import recieve_token, send_token
@@ -199,6 +197,23 @@ class BaseUpstreamProvider:
"""
return model_id
def normalize_request_path(self, path: str, model_obj: Model | None = None) -> str:
"""Normalize request path before forwarding to upstream."""
if path.startswith("v1/"):
return path.replace("v1/", "", 1)
return path
def get_request_base_url(
self, path: str, model_obj: Model | None = None
) -> str:
"""Get upstream base URL used when building forwarding URL."""
return self.base_url.rstrip("/")
def build_request_url(self, path: str, model_obj: Model | None = None) -> str:
"""Build full upstream URL from normalized path."""
clean_path = path.lstrip("/")
return f"{self.get_request_base_url(path, model_obj)}/{clean_path}"
def prepare_responses_request_body(
self, body: bytes | None, model_obj: Model
) -> bytes | None:
@@ -525,10 +540,17 @@ class BaseUpstreamProvider:
session,
max_cost_for_model,
)
remaining_balance_msats = fresh_key.balance
# Merge cost into usage
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
usage_chunk_data["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
usage_chunk_data["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
@@ -536,6 +558,12 @@ class BaseUpstreamProvider:
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
}
usage_chunk_data["metadata"]["routstr"]["cost"][
"sats_cost"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["metadata"]["routstr"]["cost"][
"remaining_balance_msats"
] = remaining_balance_msats
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception as e:
@@ -626,14 +654,31 @@ class BaseUpstreamProvider:
key, response_json, session, deducted_max_cost
)
await session.refresh(key)
remaining_balance_msats = key.balance
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
response_json["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
response_json["cost"] = cost_data
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.info(
"Payment adjustment completed for non-streaming",
@@ -803,6 +848,7 @@ class BaseUpstreamProvider:
session,
max_cost_for_model,
)
remaining_balance_msats = fresh_key.balance
# Merge cost into usage chunk
if (
"response" in usage_chunk_data
@@ -811,10 +857,22 @@ class BaseUpstreamProvider:
usage_chunk_data["response"]["usage"]["cost"] = (
cost_data.get("total_usd", 0.0)
)
usage_chunk_data["response"]["usage"][
"cost_sats"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["response"]["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
elif "usage" in usage_chunk_data:
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
usage_chunk_data["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
usage_chunk_data["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
@@ -823,6 +881,12 @@ class BaseUpstreamProvider:
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
}
usage_chunk_data["metadata"]["routstr"]["cost"][
"sats_cost"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["metadata"]["routstr"]["cost"][
"remaining_balance_msats"
] = remaining_balance_msats
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception:
@@ -907,14 +971,31 @@ class BaseUpstreamProvider:
key, response_json, session, deducted_max_cost
)
await session.refresh(key)
remaining_balance_msats = key.balance
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
response_json["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
response_json["cost"] = cost_data
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.info(
"Payment adjustment completed for non-streaming Responses API",
@@ -1037,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)
@@ -1215,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):
@@ -1246,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(
@@ -1281,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)
@@ -1437,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):
@@ -1468,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(
@@ -1493,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",
@@ -3023,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",

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

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

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,12 +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/api/settings")
assert response.status_code == 403
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

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

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

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

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

@@ -119,7 +119,7 @@ export function BatchOverrideDialog({
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder={JSON.stringify(sampleJson, null, 2)}
className='min-h-[400px] font-mono text-xs'
className='min-h-[260px] font-mono text-xs sm:min-h-[400px]'
/>
</div>

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

@@ -12,6 +12,8 @@ import {
} 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,
@@ -224,7 +226,7 @@ export function ChildKeyCreator({
</div>
{costPerKeyMsats !== undefined && (
<div className='text-right'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide'>
Unit Cost
</p>
<p className='text-primary text-sm font-bold'>
@@ -238,9 +240,9 @@ export function ChildKeyCreator({
<div className='space-y-4'>
{baseUrl && (
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Parent API Key
</label>
</Label>
<Input
value={activeApiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
@@ -251,7 +253,7 @@ export function ChildKeyCreator({
)}
<div className='flex flex-col gap-6'>
{configs.map((config, index) => (
{configs.map((config) => (
<div
key={config.id}
className='bg-muted/30 relative space-y-4 rounded-lg border p-4 pt-6'
@@ -268,9 +270,9 @@ export function ChildKeyCreator({
)}
<div className='flex flex-col gap-4 sm:flex-row sm:items-end'>
<div className='w-full space-y-2 sm:w-32'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Number of keys
</label>
</Label>
<Input
type='number'
min={1}
@@ -368,12 +370,12 @@ export function ChildKeyCreator({
{newKeys.length > 0 && (
<div className='mt-6 space-y-4'>
<Alert className='border-green-200 bg-green-50 dark:border-green-900/20 dark:bg-green-900/10'>
<AlertTitle className='text-green-800 dark:text-green-400'>
<Alert>
<AlertTitle>
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
Generated
</AlertTitle>
<AlertDescription className='text-green-700 dark:text-green-500'>
<AlertDescription>
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
You won&apos;t be able to see them again.
{resultInfo && (
@@ -387,14 +389,14 @@ export function ChildKeyCreator({
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs font-medium uppercase'>
<span className='text-muted-foreground text-xs font-medium'>
Generated Keys ({newKeys.length})
</span>
{newKeys.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-7 text-[10px] uppercase'
className='h-7 text-[10px]'
onClick={copyAllToClipboard}
>
<Copy className='mr-1 h-3 w-3' />
@@ -418,7 +420,7 @@ export function ChildKeyCreator({
onClick={() => copyToClipboard(key)}
>
{copiedKey === key ? (
<Check className='h-3.5 w-3.5 text-green-500' />
<Check className='h-3.5 w-3.5' />
) : (
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
)}
@@ -430,15 +432,15 @@ export function ChildKeyCreator({
{newKeys.length > 3 && (
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Bulk Export (All Keys)
</label>
</Label>
<div className='relative'>
<textarea
<Textarea
readOnly
value={newKeys.join('\n')}
rows={Math.min(newKeys.length, 6)}
className='bg-muted/30 w-full rounded-md border p-3 font-mono text-[10px] focus:outline-none'
className='bg-muted/30 font-mono text-[10px] leading-relaxed'
/>
<Button
size='sm'
@@ -468,9 +470,9 @@ export function ChildKeyCreator({
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Child API Key
</label>
</Label>
<Input
value={childKeyToCheck}
onChange={(e) => setChildKeyToCheck(e.target.value)}
@@ -531,9 +533,7 @@ export function ChildKeyCreator({
<Badge variant='destructive'>Expired</Badge>
)}
{!keyStatus.is_drained && !keyStatus.is_expired && (
<Badge className='bg-green-600 hover:bg-green-700'>
Active
</Badge>
<Badge>Active</Badge>
)}
</div>
</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

@@ -1,5 +1,13 @@
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;
@@ -24,48 +32,57 @@ export function KeyOptions({
<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'>
<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>
</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'>
<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>
</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'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Shield className='h-3 w-3' />
Reset Policy
</label>
<select
value={balanceLimitReset}
onChange={(e) => setBalanceLimitReset(e.target.value)}
className='bg-background border-input flex h-9 w-full rounded-md border px-3 py-1 text-xs shadow-sm transition-colors'
</Label>
<Select
value={balanceLimitReset || 'none'}
onValueChange={(value) =>
setBalanceLimitReset(value === 'none' ? '' : value)
}
>
<option value=''>None</option>
<option value='daily'>Daily</option>
<option value='weekly'>Weekly</option>
<option value='monthly'>Monthly</option>
</select>
<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'>

View File

@@ -1,7 +1,7 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import { Copy, KeyRound, RefreshCcw, Trash2 } from 'lucide-react';
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -9,14 +9,10 @@ import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { KeyOptions } from '@/components/key-options';
import { WalletBalanceStats } from './wallet-balance-stats';
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
export type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
@@ -54,19 +50,31 @@ async function fetchWalletInfo(
api_key: string;
balance: number;
reserved?: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
child_keys?: ChildKeyInfo[];
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
@@ -147,11 +155,25 @@ export function CashuPaymentWorkflow({
const payload = (await response.json()) as {
api_key: string;
balance: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
};
const snapshot: WalletSnapshot = {
apiKey: payload.api_key,
balanceMsats: payload.balance ?? 0,
reservedMsats: 0,
isChild: payload.is_child ?? false,
parentKey: payload.parent_key ?? null,
totalRequests: payload.total_requests ?? 0,
totalSpent: payload.total_spent ?? 0,
balanceLimit: payload.balance_limit ?? null,
balanceLimitReset: payload.balance_limit_reset ?? null,
validityDate: payload.validity_date ?? null,
};
setApiKeyInput(snapshot.apiKey);
@@ -283,17 +305,14 @@ export function CashuPaymentWorkflow({
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='text-primary h-5 w-5' />
API key workflow
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-xl'>API key workflow</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
Sections expand as soon as you interact
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
@@ -337,7 +356,7 @@ export function CashuPaymentWorkflow({
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
@@ -376,45 +395,17 @@ export function CashuPaymentWorkflow({
</div>
</div>
{showManageDetails && (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
<WalletBalanceStats
balanceMsats={walletInfo?.balanceMsats}
reservedMsats={walletInfo?.reservedMsats}
/>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>3 · Top up</span>
{showTopupDetails && (
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
@@ -457,7 +448,7 @@ export function CashuPaymentWorkflow({
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>4 · Refund</span>
</header>
<div className='flex flex-wrap gap-2'>

View File

@@ -2,38 +2,35 @@
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Bolt, Copy, RefreshCcw, ShieldCheck, Terminal } from 'lucide-react';
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
import {
CashuPaymentWorkflow,
type RefundReceipt,
} from './cashu-payment-workflow';
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
import { ApiKeyManager } from './api-key-manager';
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
import { ChildKeyCreator } from '@/components/child-key-creator';
type NodeInfo = {
name: string;
description: string;
version: string;
npub?: string | null;
mints: string[];
http_url?: string | null;
onion_url?: string | null;
name?: string;
description?: string;
version?: string;
http_url?: string;
onion_url?: string;
npub?: string;
mints?: string[];
child_key_cost_msats?: number;
};
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
@@ -170,7 +167,7 @@ export function CheatSheet(): JSX.Element {
const refundToken = refundReceipt?.token ?? null;
return (
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
<div className='bg-background min-h-screen'>
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
<section className='relative space-y-3 text-center md:text-left'>
<div className='absolute top-0 right-0 hidden md:block'>
@@ -178,7 +175,7 @@ export function CheatSheet(): JSX.Element {
<a href='/login'>Admin</a>
</Button>
</div>
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider uppercase'>
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider'>
<Bolt className='text-primary h-4 w-4' />
Routstr cheat sheet
</div>
@@ -196,11 +193,8 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='text-primary h-4 w-4' />
Node identity
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-lg'>Node identity</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
/v1/info snapshot
</p>
</div>
@@ -217,9 +211,44 @@ export function CheatSheet(): JSX.Element {
</CardHeader>
<CardContent className='space-y-4'>
{isInfoLoading && (
<p className='text-muted-foreground text-sm'>
Loading node profile
</p>
<div className='space-y-4'>
<div className='space-y-2'>
<Skeleton className='h-8 w-56 max-w-full' />
<Skeleton className='h-4 w-80 max-w-full' />
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div className='space-y-2'>
<Skeleton className='h-3 w-16' />
<Skeleton className='h-5 w-24' />
</div>
<div className='space-y-2'>
<Skeleton className='h-3 w-12' />
<Skeleton className='h-5 w-40 max-w-full' />
</div>
<div className='space-y-2 sm:col-span-2'>
<Skeleton className='h-3 w-12' />
<Skeleton className='h-5 w-full max-w-[26rem]' />
</div>
<div className='space-y-2 sm:col-span-2'>
<Skeleton className='h-3 w-10' />
<div className='flex items-center gap-2'>
<Skeleton className='h-5 max-w-[28rem] flex-1' />
<Skeleton className='h-7 w-7 rounded-md' />
</div>
</div>
</dl>
<div className='space-y-2'>
<Skeleton className='h-3 w-20' />
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`mint-skeleton-${index}`}
className='h-6 w-28 rounded-full'
/>
))}
</div>
</div>
</div>
)}
{isInfoError && !isInfoLoading && (
<p className='text-destructive text-sm'>
@@ -236,7 +265,7 @@ export function CheatSheet(): JSX.Element {
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs tracking-wide'>
Version
</dt>
<dd className='text-base font-medium'>
@@ -244,7 +273,7 @@ export function CheatSheet(): JSX.Element {
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs tracking-wide'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
@@ -253,7 +282,7 @@ export function CheatSheet(): JSX.Element {
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs tracking-wide'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
@@ -263,7 +292,7 @@ export function CheatSheet(): JSX.Element {
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs tracking-wide'>
npub
</dt>
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
@@ -281,11 +310,11 @@ export function CheatSheet(): JSX.Element {
)}
</dl>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<p className='text-muted-foreground text-xs tracking-wide'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
{nodeInfo.mints.length ? (
{nodeInfo.mints?.length ? (
nodeInfo.mints.map((mint) => (
<Badge
key={mint}
@@ -309,11 +338,8 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Terminal className='text-primary h-4 w-4' />
Quick docs
</CardTitle>
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-lg'>Quick docs</CardTitle>
<span className='text-muted-foreground text-xs tracking-wide'>
curl-ready
</span>
</CardHeader>
@@ -334,11 +360,12 @@ export function CheatSheet(): JSX.Element {
<Copy className='h-4 w-4' />
</Button>
</div>
<div className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
<pre className='break-all whitespace-pre-wrap'>
<ScrollArea className='bg-muted w-full rounded-lg border'>
<pre className='w-max min-w-full p-4 font-mono text-sm leading-6 whitespace-pre'>
{curlSnippet}
</pre>
</div>
<ScrollBar orientation='horizontal' />
</ScrollArea>
<div className='flex gap-2'>
<Button
variant='secondary'
@@ -364,10 +391,11 @@ export function CheatSheet(): JSX.Element {
</section>
<Tabs defaultValue='cashu' className='w-full'>
<TabsList className='grid w-full grid-cols-4'>
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
<TabsList className='grid w-full grid-cols-5'>
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
<TabsTrigger value='details'>Key Details</TabsTrigger>
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
</TabsList>
@@ -402,7 +430,7 @@ export function CheatSheet(): JSX.Element {
</CardHeader>
<CardContent className='space-y-2'>
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Cashu refund token</span>
<Button
variant='outline'
@@ -426,6 +454,16 @@ export function CheatSheet(): JSX.Element {
)}
</TabsContent>
<TabsContent value='details' className='space-y-4'>
<KeyInfoDetails
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
walletInfo={walletInfo}
onApiKeyChanged={handleApiKeyChanged}
onWalletInfoUpdated={handleWalletInfoUpdated}
/>
</TabsContent>
<TabsContent value='child-keys' className='space-y-4'>
<ChildKeyCreator
baseUrl={normalizedBaseUrl}

View File

@@ -0,0 +1,411 @@
'use client';
import { type JSX, useState, useCallback, useEffect } from 'react';
import { Copy, RefreshCcw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { WalletService } from '@/lib/api/services/wallet';
export type ChildKeyInfo = {
api_key: string;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
};
export type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
isChild: boolean;
parentKey: string | null;
totalRequests: number;
totalSpent: number;
balanceLimit: number | null;
balanceLimitReset: string | null;
validityDate: number | null;
childKeys?: ChildKeyInfo[];
};
interface KeyInfoDetailsProps {
baseUrl: string;
apiKey?: string;
walletInfo?: WalletSnapshot | null;
onApiKeyChanged?: (apiKey: string) => void;
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
}
export function KeyInfoDetails({
baseUrl,
apiKey = '',
walletInfo = null,
onApiKeyChanged,
onWalletInfoUpdated,
}: KeyInfoDetailsProps): JSX.Element {
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isResetting, setIsResetting] = useState<string | null>(null);
// Sync internal state with props if they change
useEffect(() => {
setApiKeyInput(apiKey);
}, [apiKey]);
const fetchDetails = useCallback(
async (keyToFetch: string) => {
setIsRefreshing(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
headers: { Authorization: `Bearer ${keyToFetch}` },
});
if (!response.ok) {
throw new Error('Failed to fetch key info');
}
const payload = await response.json();
const snapshot: WalletSnapshot = {
apiKey: payload.api_key || keyToFetch,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
onWalletInfoUpdated?.(snapshot);
toast.success('Key details synced');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to fetch details'
);
} finally {
setIsRefreshing(false);
}
},
[baseUrl, onWalletInfoUpdated]
);
const handleRefresh = async () => {
if (!apiKeyInput) return;
await fetchDetails(apiKeyInput);
};
const handleKeyChange = (newKey: string) => {
setApiKeyInput(newKey);
onApiKeyChanged?.(newKey);
// Optionally clear info when key changes
if (newKey !== apiKey) {
onWalletInfoUpdated?.(null);
}
};
const handleCopy = (value: string) => {
navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
};
const handleResetSpent = async (childKey: string) => {
if (!walletInfo || walletInfo.isChild) return;
setIsResetting(childKey);
try {
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
toast.success('Child key spent reset');
await fetchDetails(apiKeyInput);
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to reset child key'
);
} finally {
setIsResetting(null);
}
};
const formatSats = (msats: number) =>
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
const formatMsats = (msats: number) =>
new Intl.NumberFormat('en-US').format(msats);
const formatDate = (timestamp: number | null) =>
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
return (
<div className='space-y-6'>
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='text-xl'>Key Information</CardTitle>
<CardDescription>
Enter an API key to view its balance, consumption, and child keys.
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10 shrink-0'
onClick={() => handleCopy(apiKeyInput)}
disabled={!apiKeyInput}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='min-w-[80px] gap-1'
onClick={handleRefresh}
disabled={isRefreshing || !apiKeyInput}
>
<RefreshCcw
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
/>
{isRefreshing ? 'Syncing...' : 'Sync'}
</Button>
</div>
</div>
</CardContent>
</Card>
{walletInfo && (
<>
<div className='grid gap-4 md:grid-cols-2'>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Status & Identity</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Type</span>
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
</Badge>
</div>
{walletInfo.parentKey && (
<div className='space-y-1'>
<span className='text-muted-foreground text-xs tracking-wider'>
Parent Key
</span>
<div className='flex items-center gap-2'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{walletInfo.parentKey}
</code>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(walletInfo.parentKey!)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Validity
</span>
<span className='text-sm font-medium'>
{formatDate(walletInfo.validityDate)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Consumption</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
{walletInfo.balanceLimit !== null && (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spend Limit
</span>
<span className='font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceLimit)} sats
</span>
</div>
{walletInfo.balanceLimitReset && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Reset Policy
</span>
<Badge variant='outline' className='capitalize'>
{walletInfo.balanceLimitReset}
</Badge>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{!walletInfo.isChild &&
walletInfo.childKeys &&
walletInfo.childKeys.length > 0 && (
<Card>
<CardHeader>
<CardTitle className='text-lg'>
Child Keys ({walletInfo.childKeys.length})
</CardTitle>
<CardDescription>
Secondary keys using this account&apos;s balance
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{walletInfo.childKeys.map((ck) => (
<div
key={ck.api_key}
className='space-y-3 rounded-lg border p-4'
>
<div className='flex items-center justify-between gap-4'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{ck.api_key}
</code>
<div className='flex gap-1'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(ck.api_key)}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
className='text-destructive h-8 w-8'
title='Reset consumption'
disabled={isResetting === ck.api_key}
onClick={() => handleResetSpent(ck.api_key)}
>
{isResetting === ck.api_key ? (
<RefreshCcw className='h-4 w-4 animate-spin' />
) : (
<RotateCcw className='h-4 w-4' />
)}
</Button>
</div>
</div>
<div className='grid grid-cols-2 gap-4 text-xs sm:grid-cols-5'>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Requests
</p>
<p className='font-mono font-medium'>
{ck.total_requests}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Spent
</p>
<p className='font-mono font-medium'>
{formatSats(ck.total_spent)} sats
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Limit
</p>
<p className='font-mono font-medium'>
{ck.balance_limit
? `${formatSats(ck.balance_limit)} sats`
: 'None'}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Policy
</p>
<p className='font-medium capitalize'>
{ck.balance_limit_reset || 'None'}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Expires
</p>
<p className='font-medium'>
{formatDate(ck.validity_date)}
</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
<div className='flex justify-center'>
<Button
variant='ghost'
size='sm'
onClick={handleRefresh}
disabled={isRefreshing}
className='text-muted-foreground'
>
<RefreshCcw
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
/>
Last synced: {new Date().toLocaleTimeString()}
</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { type JSX, useCallback, useState } from 'react';
import Image from 'next/image';
import { Copy, Zap, CheckCircle, Loader2 } from 'lucide-react';
import { Copy, CheckCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import QRCode from 'qrcode';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -10,13 +10,9 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { KeyOptions } from '@/components/key-options';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
import type { WalletSnapshot } from './key-info-details';
type LightningInvoice = {
invoice_id: string;
@@ -40,6 +36,24 @@ interface LightningPaymentWorkflowProps {
onApiKeyCreated?: (apiKey: string, walletInfo: WalletSnapshot) => void;
}
interface InvoiceDetailsCardProps {
label: string;
amountSats: number;
bolt11: string;
qrCode: string;
waiting: boolean;
helperText: string;
onCopy: () => void;
}
interface ApiKeyResultAlertProps {
title: string;
description: string;
apiKey: string;
onCopy: () => void;
onDismiss: () => void;
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
@@ -61,6 +75,113 @@ async function generateQRCodeSVG(text: string): Promise<string> {
}
}
function InvoiceDetailsCard({
label,
amountSats,
bolt11,
qrCode,
waiting,
helperText,
onCopy,
}: InvoiceDetailsCardProps): JSX.Element {
return (
<Card className='bg-muted/30 shadow-none'>
<CardContent className='space-y-3 p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>
{label} ({amountSats} sats)
</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={onCopy}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
{waiting && (
<Alert>
<Loader2 className='h-4 w-4 animate-spin' />
<AlertDescription>Waiting for payment...</AlertDescription>
</Alert>
)}
{qrCode && (
<div className='flex justify-center'>
<Image
src={qrCode}
alt='QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<Textarea
value={bolt11}
readOnly
rows={4}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-center text-xs'>
{helperText}
</p>
</CardContent>
</Card>
);
}
function ApiKeyResultAlert({
title,
description,
apiKey,
onCopy,
onDismiss,
}: ApiKeyResultAlertProps): JSX.Element {
return (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertTitle className='flex items-center justify-between gap-2'>
<span>{title}</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={onCopy}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</AlertTitle>
<AlertDescription className='space-y-3'>
<Textarea
value={apiKey}
readOnly
rows={2}
className='font-mono text-xs'
/>
<div className='flex items-center justify-between gap-2'>
<span>{description}</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={onDismiss}
>
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
);
}
export function LightningPaymentWorkflow({
baseUrl,
onApiKeyCreated,
@@ -215,6 +336,13 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setCreatedApiKey(status.api_key);
@@ -289,6 +417,13 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setTopupApiKeyResult(status.api_key);
@@ -343,6 +478,13 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setRecoveredApiKey(status.api_key);
@@ -372,17 +514,14 @@ export function LightningPaymentWorkflow({
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<Zap className='text-primary h-5 w-5' />
Lightning Payment Workflow
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-xl'>Lightning Payment Workflow</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
Create and manage API keys using Lightning Network payments
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>1 · Create key with Lightning</span>
{showCreateDetails && (
<span className='text-primary'>Amount specified</span>
@@ -418,95 +557,25 @@ export function LightningPaymentWorkflow({
</Button>
{createInvoice && (
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>
Lightning Invoice ({createInvoice.amount_sats} sats)
</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(createInvoice.bolt11)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
{isWaitingPayment && (
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
Waiting for payment...
</span>
</div>
)}
{createQRCode && (
<div className='flex justify-center'>
<Image
src={createQRCode}
alt='QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<Textarea
value={createInvoice.bolt11}
readOnly
rows={4}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-center text-xs'>
Scan QR code or copy invoice. Payment will be detected
automatically.
</p>
</div>
<InvoiceDetailsCard
label='Lightning Invoice'
amountSats={createInvoice.amount_sats}
bolt11={createInvoice.bolt11}
qrCode={createQRCode}
waiting={isWaitingPayment}
helperText='Scan QR code or copy invoice. Payment will be detected automatically.'
onCopy={() => handleCopy(createInvoice.bolt11)}
/>
)}
{createdApiKey && (
<div className='space-y-3 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-green-700 uppercase dark:text-green-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>API Key Created Successfully</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-green-300 text-xs hover:bg-green-100 dark:border-green-700 dark:hover:bg-green-900'
onClick={() => handleCopy(createdApiKey)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={createdApiKey}
readOnly
rows={2}
className='border-green-200 bg-white font-mono text-xs dark:border-green-800 dark:bg-green-950/50'
/>
<div className='flex items-center justify-between text-xs text-green-700 dark:text-green-300'>
<span>Your API key is ready to use!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-green-700 hover:text-green-800 dark:text-green-300 dark:hover:text-green-200'
onClick={() => setCreatedApiKey('')}
>
Dismiss
</Button>
</div>
</div>
<ApiKeyResultAlert
title='API Key Created Successfully'
description='Your API key is ready to use!'
apiKey={createdApiKey}
onCopy={() => handleCopy(createdApiKey)}
onDismiss={() => setCreatedApiKey('')}
/>
)}
</div>
</div>
@@ -515,7 +584,7 @@ export function LightningPaymentWorkflow({
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>2 · Top up existing key</span>
{showTopupDetails && (
<span className='text-primary'>Ready to create invoice</span>
@@ -551,93 +620,25 @@ export function LightningPaymentWorkflow({
</Button>
{topupInvoice && (
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>Topup Invoice ({topupInvoice.amount_sats} sats)</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(topupInvoice.bolt11)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
{topupQRCode && (
<div className='flex justify-center'>
<Image
src={topupQRCode}
alt='QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<Textarea
value={topupInvoice.bolt11}
readOnly
rows={4}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-center text-xs'>
Scan QR code or copy invoice. Balance will be added
automatically.
</p>
{isWaitingTopupPayment && (
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
Waiting for payment...
</span>
</div>
)}
</div>
<InvoiceDetailsCard
label='Topup Invoice'
amountSats={topupInvoice.amount_sats}
bolt11={topupInvoice.bolt11}
qrCode={topupQRCode}
waiting={isWaitingTopupPayment}
helperText='Scan QR code or copy invoice. Balance will be added automatically.'
onCopy={() => handleCopy(topupInvoice.bolt11)}
/>
)}
{topupApiKeyResult && (
<div className='space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-blue-700 uppercase dark:text-blue-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>Topup Successful</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-blue-300 text-xs hover:bg-blue-100 dark:border-blue-700 dark:hover:bg-blue-900'
onClick={() => handleCopy(topupApiKeyResult)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={topupApiKeyResult}
readOnly
rows={2}
className='border-blue-200 bg-white font-mono text-xs dark:border-blue-800 dark:bg-blue-950/50'
/>
<div className='flex items-center justify-between text-xs text-blue-700 dark:text-blue-300'>
<span>Balance has been added to your API key!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-blue-700 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200'
onClick={() => setTopupApiKeyResult('')}
>
Dismiss
</Button>
</div>
</div>
<ApiKeyResultAlert
title='Topup Successful'
description='Balance has been added to your API key!'
apiKey={topupApiKeyResult}
onCopy={() => handleCopy(topupApiKeyResult)}
onDismiss={() => setTopupApiKeyResult('')}
/>
)}
</div>
)}
@@ -646,7 +647,7 @@ export function LightningPaymentWorkflow({
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>3 · Recover from invoice</span>
{showRecoverDetails && (
<span className='text-primary'>Invoice provided</span>
@@ -677,42 +678,13 @@ export function LightningPaymentWorkflow({
)}
{recoveredApiKey && (
<div className='space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-purple-700 uppercase dark:text-purple-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>API Key Recovered</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-purple-300 text-xs hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900'
onClick={() => handleCopy(recoveredApiKey)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={recoveredApiKey}
readOnly
rows={2}
className='border-purple-200 bg-white font-mono text-xs dark:border-purple-800 dark:bg-purple-950/50'
/>
<div className='flex items-center justify-between text-xs text-purple-700 dark:text-purple-300'>
<span>Your recovered API key is ready to use!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-purple-700 hover:text-purple-800 dark:text-purple-300 dark:hover:text-purple-200'
onClick={() => setRecoveredApiKey('')}
>
Dismiss
</Button>
</div>
</div>
<ApiKeyResultAlert
title='API Key Recovered'
description='Your recovered API key is ready to use!'
apiKey={recoveredApiKey}
onCopy={() => handleCopy(recoveredApiKey)}
onDismiss={() => setRecoveredApiKey('')}
/>
)}
</section>
</CardContent>

View File

@@ -0,0 +1,63 @@
'use client';
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from '@/components/ui/card';
interface WalletBalanceStatsProps {
balanceMsats?: number;
reservedMsats?: number;
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function WalletMetricCard({
label,
valueMsats,
}: {
label: string;
valueMsats?: number;
}) {
const hasValue = typeof valueMsats === 'number';
return (
<Card className='shadow-none'>
<CardHeader className='p-3 pb-2'>
<CardDescription className='text-[0.65rem] tracking-wide'>
{label}
</CardDescription>
</CardHeader>
<CardContent className='px-3 pt-0 pb-3'>
<p className='text-xl font-semibold tabular-nums'>
{hasValue ? `${formatSats(valueMsats)} sats` : '-'}
</p>
{hasValue && (
<p className='text-muted-foreground text-xs tabular-nums'>
{formatMsats(valueMsats)} msats
</p>
)}
</CardContent>
</Card>
);
}
export function WalletBalanceStats({
balanceMsats,
reservedMsats,
}: WalletBalanceStatsProps) {
return (
<div className='grid gap-3 sm:grid-cols-2'>
<WalletMetricCard label='Spendable' valueMsats={balanceMsats} />
<WalletMetricCard label='Reserved' valueMsats={reservedMsats} />
</div>
);
}

View File

@@ -0,0 +1,419 @@
import type { Model } from '@/lib/api/schemas/models';
import type { DisplayUnit } from '@/lib/types/units';
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ArrowRight,
AudioLines,
Ban,
CheckCircle,
Edit3,
FileText,
ImageIcon,
Layers3,
MoreVertical,
Trash2,
Type,
Video,
Waypoints,
} from 'lucide-react';
interface ModelItemCardProps {
model: Model;
displayUnit: DisplayUnit;
usdPerSat: number | null;
isSelected: boolean;
hasEffectiveApiKey: boolean;
hasIndividualSettings: boolean;
onHoverStart: () => void;
onHoverEnd: () => void;
onToggleSelection: () => void;
onEdit: () => void;
onOverride: () => void;
onDisable: () => void;
onEnable: () => void;
onDelete: () => void;
}
type ModelModality =
| 'text'
| 'image'
| 'file'
| 'audio'
| 'video'
| 'embedding'
| 'multimodal';
const MODALITY_ORDER: ModelModality[] = [
'text',
'image',
'file',
'audio',
'video',
'embedding',
'multimodal',
];
function extractModalities(part: string): ModelModality[] {
const normalized = part.toLowerCase();
const modalities = MODALITY_ORDER.filter((modality) =>
normalized.includes(modality)
);
return modalities.length > 0 ? modalities : ['text'];
}
function getModelTypeParts(modelType: string): {
inputs: ModelModality[];
outputs: ModelModality[];
} {
const [inputPart, outputPart] = modelType
.split('->')
.map((part) => part.trim());
return {
inputs: extractModalities(inputPart || modelType),
outputs: outputPart ? extractModalities(outputPart) : [],
};
}
function ModelTypeIcons({ modelType }: { modelType: string }) {
const { inputs, outputs } = getModelTypeParts(modelType);
const renderIcon = (modality: ModelModality, index: number) => {
const props = {
className: 'h-3 w-3 shrink-0',
'aria-hidden': true as const,
};
switch (modality) {
case 'image':
return <ImageIcon key={`${modality}-${index}`} {...props} />;
case 'file':
return <FileText key={`${modality}-${index}`} {...props} />;
case 'audio':
return <AudioLines key={`${modality}-${index}`} {...props} />;
case 'video':
return <Video key={`${modality}-${index}`} {...props} />;
case 'embedding':
return <Waypoints key={`${modality}-${index}`} {...props} />;
case 'multimodal':
return <Layers3 key={`${modality}-${index}`} {...props} />;
case 'text':
default:
return <Type key={`${modality}-${index}`} {...props} />;
}
};
return (
<span
className='text-muted-foreground inline-flex max-w-full min-w-0 items-center gap-0.5 overflow-hidden'
title={modelType}
aria-label={modelType}
>
{inputs.map(renderIcon)}
{outputs.length > 0 && (
<>
<ArrowRight className='h-2.5 w-2.5 shrink-0 opacity-55' aria-hidden />
{outputs.map(renderIcon)}
</>
)}
<span className='sr-only'>{modelType}</span>
</span>
);
}
export function ModelItemCard({
model,
displayUnit,
usdPerSat,
isSelected,
hasEffectiveApiKey,
hasIndividualSettings,
onHoverStart,
onHoverEnd,
onToggleSelection,
onEdit,
onOverride,
onDisable,
onEnable,
onDelete,
}: ModelItemCardProps) {
const isDisabled = Boolean(model.soft_deleted);
const statusLabel = isDisabled ? 'Disabled' : 'Enabled';
const isFreeModel = model.is_free;
const statusDotClass = cn(
'inline-block h-2.5 w-2.5 shrink-0 rounded-full',
isDisabled
? 'bg-muted-foreground/50'
: 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.14)]'
);
const modelSourceLabel =
model.api_key_type === 'remote' ? 'Remote' : 'Database';
const keySourceLabel = !hasEffectiveApiKey
? 'No API key'
: hasIndividualSettings
? 'Individual key'
: 'Group key';
const inputPriceLabel = formatUsdAmountForDisplayUnit(
model.input_cost,
displayUnit,
usdPerSat
);
const outputPriceLabel = formatUsdAmountForDisplayUnit(
model.output_cost,
displayUnit,
usdPerSat
);
const pricingLabel = isFreeModel
? 'Free'
: `Input ${inputPriceLabel} · Output ${outputPriceLabel}`;
const renderActionsMenu = (className: string) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon-xs'
onClick={(event) => event.stopPropagation()}
className={className}
aria-label={`Model actions for ${model.name}`}
title={`Model actions for ${model.name}`}
>
<MoreVertical className='text-muted-foreground hover:text-foreground h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-52'>
{model.api_key_type !== 'remote' && (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onEdit();
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
)}
{model.api_key_type === 'remote' && (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onOverride();
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Override
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{model.soft_deleted ? (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onEnable();
}}
className='text-foreground'
>
<CheckCircle className='mr-2 h-4 w-4' />
Enable Model
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDisable();
}}
className='text-foreground'
>
<Ban className='mr-2 h-4 w-4' />
Disable Model
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className='text-destructive focus:text-destructive'
>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<Card
className={cn(
"after:bg-border/80 relative overflow-hidden rounded-none border-0 bg-transparent py-0 shadow-none ring-0 transition-colors duration-150 after:absolute after:right-3 after:bottom-0 after:left-3 after:h-px after:content-[''] last:after:hidden md:after:hidden",
'hover:bg-muted/25',
isSelected && 'bg-primary/12',
model.soft_deleted && 'bg-muted/20 opacity-80'
)}
onMouseEnter={onHoverStart}
onMouseLeave={onHoverEnd}
>
<div className='px-2 py-2 md:flex md:items-center md:gap-2 md:py-1'>
<div className='hidden md:flex md:items-center'>
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelection}
onClick={(event) => event.stopPropagation()}
className='border-border/90 data-checked:ring-primary/40 size-4 flex-shrink-0 data-checked:ring-2'
/>
</div>
<div className='min-w-0 flex-1 px-0.5 text-left'>
<div className='hidden min-w-0 items-center gap-x-3 gap-y-1 md:grid md:grid-cols-[minmax(210px,1.45fr)_112px_96px_120px] lg:grid-cols-[minmax(230px,1.65fr)_128px_96px_120px_minmax(220px,1fr)]'>
<div className='min-w-0'>
<div
className='flex min-w-0 items-center gap-2'
title={statusLabel}
aria-label={statusLabel}
>
<span className={statusDotClass} />
<h3
className={cn(
'min-w-0 truncate text-sm font-medium',
model.soft_deleted && 'text-muted-foreground'
)}
title={model.name}
>
{model.name}
</h3>
<span className='sr-only'>{statusLabel}</span>
</div>
</div>
<span
className='hidden min-w-0 overflow-hidden md:flex md:items-center'
title={model.modelType}
>
<ModelTypeIcons modelType={model.modelType} />
</span>
<span
className='text-muted-foreground hidden truncate text-xs md:block'
title={modelSourceLabel}
>
{modelSourceLabel}
</span>
<span
className={cn(
'text-muted-foreground hidden truncate text-xs md:block',
!hasEffectiveApiKey && 'text-destructive'
)}
title={keySourceLabel}
>
{keySourceLabel}
</span>
<span
className='text-muted-foreground hidden truncate text-right text-xs whitespace-nowrap lg:block'
title={pricingLabel}
>
{pricingLabel}
</span>
</div>
<div className='grid grid-cols-[16px_minmax(0,1fr)] gap-x-2.5 gap-y-2.5 md:hidden'>
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelection}
onClick={(event) => event.stopPropagation()}
className='border-border/90 data-checked:ring-primary/40 mt-0.5 size-4 flex-shrink-0 self-start data-checked:ring-2'
/>
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2'>
<div
className='flex min-w-0 items-center gap-2.5'
title={statusLabel}
aria-label={statusLabel}
>
<span className={statusDotClass} />
<h3
className={cn(
'line-clamp-2 min-w-0 flex-1 text-[15px] leading-5 font-medium',
model.soft_deleted && 'text-muted-foreground'
)}
title={model.name}
>
{model.name}
</h3>
<span className='sr-only'>{statusLabel}</span>
</div>
{renderActionsMenu(
'hover:bg-muted/50 dark:hover:bg-muted/80 -mr-1 h-7 w-7 shrink-0 self-start'
)}
</div>
<div aria-hidden />
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px]'>
<span
className='text-foreground/80 inline-flex items-center'
title={model.modelType}
>
<ModelTypeIcons modelType={model.modelType} />
</span>
<span className='text-border/80' aria-hidden>
·
</span>
<span className='inline-flex items-center'>
{modelSourceLabel}
</span>
<span
className={cn(
'inline-flex items-center',
!hasEffectiveApiKey && 'text-destructive'
)}
>
<span className='text-border/80 mr-2' aria-hidden>
·
</span>
{keySourceLabel}
</span>
</div>
<div aria-hidden />
{isFreeModel ? (
<div className='text-muted-foreground text-sm font-medium'>
Free
</div>
) : (
<div className='border-border/45 bg-background/20 grid grid-cols-2 overflow-hidden rounded-lg border'>
<div className='px-3 py-2.5'>
<div className='text-muted-foreground text-[11px]'>Input</div>
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
{inputPriceLabel}
</div>
</div>
<div className='border-border/45 border-l px-3 py-2.5'>
<div className='text-muted-foreground text-[11px]'>
Output
</div>
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
{outputPriceLabel}
</div>
</div>
</div>
)}
</div>
</div>
<div className='hidden md:block'>
{renderActionsMenu(
'hover:bg-muted/50 dark:hover:bg-muted/80 h-7 w-7 shrink-0 md:h-6 md:w-6'
)}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,243 @@
import { useMemo } from 'react';
import type { Model } from '@/lib/api/schemas/models';
import type { AdminModelGroup } from '@/lib/api/services/admin';
import type { DisplayUnit } from '@/lib/types/units';
import { ModelItemCard } from '@/components/model-item-card';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
CheckSquare,
Edit3,
Globe,
Key,
MoreVertical,
RefreshCw,
} from 'lucide-react';
interface ModelProviderSectionProps {
provider: string;
providerModels: Model[];
displayUnit: DisplayUnit;
usdPerSat: number | null;
filterProvider?: string;
groupData?: AdminModelGroup;
selectedModels: Set<string>;
onSelectProviderModels: () => void;
onDeselectProviderModels: () => void;
onEditGroup: () => void;
onRefreshProviderModels: () => void;
onDeleteAllProviderModels: () => void;
onModelHover: (modelId: string | null) => void;
onModelToggleSelection: (modelId: string) => void;
onEditModel: (model: Model) => void;
onOverrideModel: (model: Model) => void;
onEnableModel: (modelId: string) => void;
onDisableModel: (modelId: string) => void;
onDeleteModel: (modelId: string) => void;
hasEffectiveApiKey: (model: Model) => boolean;
hasIndividualSettings: (model: Model) => boolean;
}
export function ModelProviderSection({
provider,
providerModels,
displayUnit,
usdPerSat,
filterProvider,
groupData,
selectedModels,
onSelectProviderModels,
onDeselectProviderModels,
onEditGroup,
onRefreshProviderModels,
onDeleteAllProviderModels,
onModelHover,
onModelToggleSelection,
onEditModel,
onOverrideModel,
onEnableModel,
onDisableModel,
onDeleteModel,
hasEffectiveApiKey,
hasIndividualSettings,
}: ModelProviderSectionProps) {
const allProviderSelected = providerModels.every((model) =>
selectedModels.has(model.id)
);
const someProviderSelected = providerModels.some((model) =>
selectedModels.has(model.id)
);
const keyedProviderModels = useMemo(() => {
const seenKeys = new Map<string, number>();
return providerModels.map((model) => {
const baseKey = [
provider,
model.id,
model.provider_id ?? '',
model.full_name,
model.api_key_type,
].join('::');
const seenCount = seenKeys.get(baseKey) ?? 0;
seenKeys.set(baseKey, seenCount + 1);
return {
model,
renderKey:
seenCount === 0 ? baseKey : `${baseKey}::duplicate-${seenCount}`,
};
});
}, [provider, providerModels]);
if (filterProvider) {
return (
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
{keyedProviderModels.map(({ model, renderKey }) => (
<ModelItemCard
key={renderKey}
model={model}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
isSelected={selectedModels.has(model.id)}
hasEffectiveApiKey={hasEffectiveApiKey(model)}
hasIndividualSettings={hasIndividualSettings(model)}
onHoverStart={() => onModelHover(model.id)}
onHoverEnd={() => onModelHover(null)}
onToggleSelection={() => onModelToggleSelection(model.id)}
onEdit={() => onEditModel(model)}
onOverride={() => onOverrideModel(model)}
onDisable={() => onDisableModel(model.id)}
onEnable={() => onEnableModel(model.id)}
onDelete={() => onDeleteModel(model.id)}
/>
))}
</div>
);
}
return (
<Card className='overflow-hidden'>
<CardHeader className='px-3 pb-2.5 sm:px-6 sm:pb-3'>
<div className='flex items-start justify-between gap-2 sm:gap-3'>
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
<Checkbox
checked={
allProviderSelected
? true
: someProviderSelected
? 'indeterminate'
: false
}
onCheckedChange={(checked) => {
if (checked === true) {
onSelectProviderModels();
return;
}
onDeselectProviderModels();
}}
className='border-border/90 data-checked:ring-primary/35 mt-0.5 size-4 data-checked:ring-2 sm:mt-1 sm:size-5'
aria-label={`Select models for provider ${provider}`}
/>
<div className='min-w-0'>
<CardTitle className='flex items-baseline gap-1.5 text-base sm:text-lg'>
<span className='truncate'>{provider}</span>
<span className='text-muted-foreground text-sm font-normal'>
({providerModels.length} models)
</span>
</CardTitle>
<CardDescription className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm'>
{groupData?.group_url ? (
<span className='inline-flex items-center gap-1 break-all'>
<Globe className='h-3 w-3' />
{groupData.group_url}
</span>
) : (
'Using default endpoint'
)}
{groupData?.group_api_key && (
<span className='flex items-center gap-1'>
<Key className='h-3 w-3' />
Group API Key
</span>
)}
</CardDescription>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
aria-label={`Provider actions for ${provider}`}
title={`Provider actions for ${provider}`}
>
<MoreVertical className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-64 sm:w-72'>
<DropdownMenuItem onClick={onEditGroup}>
<Edit3 className='mr-2 h-4 w-4' />
Edit Group
</DropdownMenuItem>
<DropdownMenuItem onClick={onSelectProviderModels}>
<CheckSquare className='mr-2 h-4 w-4' />
Select All
</DropdownMenuItem>
<DropdownMenuItem onClick={onRefreshProviderModels}>
<RefreshCw className='mr-2 h-4 w-4' />
Refresh Models
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDeleteAllProviderModels}
className='text-muted-foreground focus:text-foreground'
>
Delete all overrides
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
{keyedProviderModels.map(({ model, renderKey }) => (
<ModelItemCard
key={renderKey}
model={model}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
isSelected={selectedModels.has(model.id)}
hasEffectiveApiKey={hasEffectiveApiKey(model)}
hasIndividualSettings={hasIndividualSettings(model)}
onHoverStart={() => onModelHover(model.id)}
onHoverEnd={() => onModelHover(null)}
onToggleSelection={() => onModelToggleSelection(model.id)}
onEdit={() => onEditModel(model)}
onOverride={() => onOverrideModel(model)}
onDisable={() => onDisableModel(model.id)}
onEnable={() => onEnableModel(model.id)}
onDelete={() => onDeleteModel(model.id)}
/>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -2,6 +2,7 @@
import React, { useState, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -97,17 +98,22 @@ export function ModelSearchFilter({
const hasActiveFilters =
searchQuery.trim() !== '' || sortOption !== 'name-asc';
const searchInputId = 'model-search-filter-input';
return (
<div
className={cn(
'flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-3',
'flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center',
className
)}
>
<div className='relative flex-1'>
<div className='relative w-full min-w-0 flex-1'>
<Label htmlFor={searchInputId} className='sr-only'>
Search models
</Label>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
id={searchInputId}
placeholder='Search models by name, provider, or description...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -115,32 +121,36 @@ export function ModelSearchFilter({
/>
</div>
<div className='flex items-center gap-2'>
<Select
value={sortOption}
onValueChange={(value: SortOption) => setSortOption(value)}
>
<SelectTrigger className='w-full sm:w-[180px]'>
<Filter className='mr-2 h-4 w-4' />
<SelectValue placeholder='Sort by' />
</SelectTrigger>
<SelectContent>
<SelectItem value='name-asc'>Name (A-Z)</SelectItem>
<SelectItem value='name-desc'>Name (Z-A)</SelectItem>
<SelectItem value='price-asc'>Price (Low to High)</SelectItem>
<SelectItem value='price-desc'>Price (High to Low)</SelectItem>
</SelectContent>
</Select>
<div className='flex items-center gap-2 sm:shrink-0'>
<div className='min-w-0 flex-1 sm:flex-none'>
<Select
value={sortOption}
onValueChange={(value: SortOption) => setSortOption(value)}
>
<SelectTrigger className='h-8 w-full sm:w-[170px]'>
<Filter className='mr-2 h-4 w-4' />
<SelectValue placeholder='Sort by' />
</SelectTrigger>
<SelectContent>
<SelectItem value='name-asc'>Name (A-Z)</SelectItem>
<SelectItem value='name-desc'>Name (Z-A)</SelectItem>
<SelectItem value='price-asc'>Price (Low to High)</SelectItem>
<SelectItem value='price-desc'>Price (High to Low)</SelectItem>
</SelectContent>
</Select>
</div>
{hasActiveFilters && (
<Button
variant='outline'
size='icon'
variant='ghost'
size='sm'
onClick={clearFilters}
className='h-9 w-9'
className='h-8 shrink-0 px-2 sm:size-8 sm:px-0'
aria-label='Clear model filters'
title='Clear filters'
>
<X className='h-4 w-4' />
<X className='hidden h-4 w-4 sm:block' />
<span className='text-xs sm:hidden'>Clear</span>
</Button>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import { type Model } from '@/lib/api/schemas/models';
import { ModelService } from '@/lib/api/services/models';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
@@ -131,13 +132,10 @@ export function ModelTester({ models }: ModelTesterProps) {
setResponse(null);
try {
console.log(`Testing model via proxy: ${selectedModel.name}`);
console.log('Request payload:', request);
const response = await ModelService.testModel(
selectedModel.id,
'chat-completions',
request as unknown as Record<string, unknown>
request
);
if (!response.success) {
@@ -205,10 +203,7 @@ export function ModelTester({ models }: ModelTesterProps) {
return (
<Card className='w-full'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Send className='h-5 w-5' />
Model Credential Tester
</CardTitle>
<CardTitle>Model Credential Tester</CardTitle>
<CardDescription>
Test model functionality by sending chat completion requests through
the secure proxy (resolves CORS and network issues)
@@ -245,7 +240,7 @@ export function ModelTester({ models }: ModelTesterProps) {
<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>
<span className='break-all'>
<strong>Endpoint:</strong> {credentials.endpointUrl}
</span>
</div>
@@ -301,19 +296,19 @@ export function ModelTester({ models }: ModelTesterProps) {
<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
<Input
id='max-tokens'
type='number'
min={1}
max={4000}
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 150)}
className='border-input bg-background w-full rounded-md border px-3 py-2 text-sm'
name='max_tokens'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='temperature'>Temperature</Label>
<input
<Input
id='temperature'
type='number'
min={0}
@@ -323,7 +318,7 @@ export function ModelTester({ models }: ModelTesterProps) {
onChange={(e) =>
setTemperature(parseFloat(e.target.value) || 0.7)
}
className='border-input bg-background w-full rounded-md border px-3 py-2 text-sm'
name='temperature'
/>
</div>
</div>
@@ -406,7 +401,7 @@ export function ModelTester({ models }: ModelTesterProps) {
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='grid grid-cols-3 gap-4 text-sm'>
<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}

View File

@@ -2,6 +2,7 @@
import Link from 'next/link';
import { type LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import {
SidebarGroup,
@@ -20,13 +21,25 @@ export function NavMain({
icon: LucideIcon;
}[];
}) {
const pathname = usePathname();
return (
<SidebarGroup>
<SidebarGroup className='px-0'>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenu className='gap-1.5'>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title} asChild>
<SidebarMenuButton
tooltip={item.title}
asChild
className='h-11 rounded-xl px-3 text-[0.95rem]'
isActive={
item.url === '/'
? pathname === '/'
: pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>

View File

@@ -3,6 +3,7 @@
import * as React from 'react';
import Link from 'next/link';
import { LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import {
SidebarGroup,
@@ -11,9 +12,11 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { cn } from '@/lib/utils';
export function NavSecondary({
items,
className,
...props
}: {
items: {
@@ -22,13 +25,25 @@ export function NavSecondary({
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const pathname = usePathname();
return (
<SidebarGroup {...props}>
<SidebarGroup className={cn('px-0', className)} {...props}>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenu className='gap-1.5'>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
className='h-11 rounded-xl px-3 text-[0.95rem]'
isActive={
item.url === '/'
? pathname === '/'
: pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>

View File

@@ -24,7 +24,7 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useAuth } from '@/lib/auth/AuthContext';
import { useAuth } from '@/lib/auth/auth-context';
import { useRouter } from 'next/navigation';
type NavUserProps = {

View File

@@ -0,0 +1,50 @@
'use client';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
icon?: ReactNode;
className?: string;
}
export function PageHeader({
title,
description,
actions,
icon,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
className
)}
>
<div className='min-w-0 space-y-1'>
<div className='flex items-center gap-2'>
{icon ? (
<span className='text-muted-foreground shrink-0'>{icon}</span>
) : null}
<h2 className='text-xl font-semibold tracking-tight sm:text-2xl'>
{title}
</h2>
</div>
{description ? (
<p className='text-muted-foreground text-sm leading-relaxed'>
{description}
</p>
) : null}
</div>
{actions ? (
<div className='flex w-full shrink-0 flex-wrap items-center gap-2 sm:w-auto sm:justify-end [&>*]:w-full sm:[&>*]:w-auto'>
{actions}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,316 @@
'use client';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { AdminService } from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface ProviderBalanceProps {
providerId: number;
platformUrl?: string | null;
}
export function ProviderBalance({
providerId,
platformUrl,
}: ProviderBalanceProps) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
const [topupError, setTopupError] = useState('');
const [isHovered, setIsHovered] = useState(false);
const [invoiceData, setInvoiceData] = useState<{
payment_request: string;
invoice_id: string;
} | null>(null);
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
null
);
const queryClient = useQueryClient();
const {
data: balanceData,
isLoading,
error,
} = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
refetchOnWindowFocus: true,
retry: 1,
});
const { data: statusData } = useQuery({
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
queryFn: () =>
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
enabled: !!invoiceData && paymentStatus === 'pending',
refetchInterval: 2000,
});
useEffect(() => {
if (statusData?.paid === true) {
setPaymentStatus('paid');
queryClient.invalidateQueries({
queryKey: ['provider-balance', providerId],
});
toast.success('Payment received!', {
description: 'Your balance has been updated.',
});
}
}, [statusData, queryClient, providerId]);
const topupMutation = useMutation({
mutationFn: async (amount: number) => {
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
return result;
},
onSuccess: (data) => {
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
setInvoiceData({
payment_request: data.topup_data.payment_request as string,
invoice_id: data.topup_data.invoice_id as string,
});
setPaymentStatus('pending');
} else {
toast.error('No invoice returned from provider');
setIsTopupDialogOpen(false);
}
},
onError: (error: Error) => {
toast.error(`Failed to initiate top-up: ${error.message}`);
},
});
const handleTopup = () => {
const amount = parseFloat(topupAmount);
if (isNaN(amount)) {
setTopupError('Please enter a valid amount');
return;
}
if (amount < 1 || amount > 500) {
setTopupError('Amount must be between $1 and $500');
return;
}
topupMutation.mutate(amount);
};
const handleTopUpClick = () => {
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
const handleCloseDialog = () => {
setIsTopupDialogOpen(false);
setTopupAmount('');
setTopupError('');
setInvoiceData(null);
setPaymentStatus(null);
};
if (isLoading) {
return <Skeleton className='h-9 w-24' />;
}
if (
error ||
!balanceData?.ok ||
balanceData.balance_data === undefined ||
balanceData.balance_data === null
) {
return null;
}
const balance = balanceData.balance_data;
let displayValue = 'N/A';
if (typeof balance === 'number') {
displayValue = `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
const b = balance as Record<string, unknown>;
if (typeof b.balance === 'number') {
displayValue = `$${b.balance.toFixed(2)}`;
} else if (typeof b.balance === 'string') {
displayValue = b.balance;
} else if (b.amount !== undefined) {
displayValue = `$${Number(b.amount).toFixed(2)}`;
}
}
return (
<>
<Button
variant='outline'
size='sm'
onClick={handleTopUpClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='w-full sm:w-auto'
>
<span className={isHovered ? undefined : 'font-mono'}>
{isHovered ? 'Top Up' : displayValue}
</span>
</Button>
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>
{paymentStatus === 'paid'
? 'Payment Confirmed!'
: 'Top Up Balance'}
</DialogTitle>
<DialogDescription>
{paymentStatus === 'paid'
? 'Your account balance has been updated.'
: invoiceData
? 'Scan the QR code or copy the Lightning invoice to pay.'
: 'Enter the amount you want to add to your account balance.'}
</DialogDescription>
</DialogHeader>
{paymentStatus === 'paid' ? (
<div className='flex flex-col items-center gap-4 py-6'>
<Badge className='gap-1.5 px-3 py-1'>
<CheckCircle2 className='h-4 w-4' />
Top-up Successful
</Badge>
<p className='text-muted-foreground text-center text-sm'>
Your provider balance has been updated.
</p>
</div>
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'>
<div className='rounded-lg border p-2'>
<Image
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(
invoiceData.payment_request
)}`}
alt='Lightning invoice QR code'
width={256}
height={256}
className='h-56 w-56 sm:h-64 sm:w-64'
unoptimized
/>
</div>
<div className='w-full space-y-2'>
<Label htmlFor='invoice'>Lightning Invoice</Label>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='invoice'
value={invoiceData.payment_request}
readOnly
className='font-mono text-xs'
/>
<Button
size='sm'
variant='outline'
onClick={() => {
navigator.clipboard.writeText(
invoiceData.payment_request
);
toast.success('Invoice copied to clipboard!');
}}
className='w-full sm:w-auto'
>
Copy
</Button>
</div>
</div>
{paymentStatus === 'pending' && (
<p className='text-muted-foreground text-center text-sm'>
Waiting for payment...
</p>
)}
</div>
) : (
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='topup_amount'>Amount (USD)</Label>
<Input
id='topup_amount'
type='number'
placeholder='Enter amount (1-500)'
value={topupAmount}
onChange={(e) => {
setTopupAmount(e.target.value);
setTopupError('');
}}
min='1'
max='500'
step='0.01'
/>
{topupError && (
<p className='text-destructive text-sm'>{topupError}</p>
)}
</div>
</div>
)}
<DialogFooter>
{paymentStatus === 'paid' ? (
<Button onClick={handleCloseDialog} className='w-full'>
Done
</Button>
) : invoiceData ? (
<Button
variant='outline'
onClick={handleCloseDialog}
className='w-full'
>
Cancel
</Button>
) : (
<>
<Button
variant='outline'
onClick={handleCloseDialog}
className='w-full sm:w-auto'
>
Cancel
</Button>
<Button
onClick={handleTopup}
disabled={topupMutation.isPending || !topupAmount}
className='w-full sm:w-auto'
>
{topupMutation.isPending
? 'Processing...'
: 'Generate Invoice'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,158 @@
import type {
AdminModel,
ProviderModels,
UpstreamProvider,
} from '@/lib/api/services/admin';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ChevronDown, ChevronUp, Database, Pencil, Trash2 } from 'lucide-react';
import { ProviderBalance } from '@/components/provider-balance';
import { ProviderModelsPanel } from '@/components/provider-models-panel';
interface ProviderCardProps {
provider: UpstreamProvider;
isExpanded: boolean;
canShowBalance: boolean;
platformUrl: string | null;
isModelsLoading: boolean;
providerModels: ProviderModels | null;
isDeletingModel: boolean;
onToggleExpansion: () => void;
onEditProvider: () => void;
onDeleteProvider: () => void;
onBatchOverride: () => void;
onAddModel: () => void;
onEditModel: (model: AdminModel) => void;
onDeleteModel: (modelId: string) => void;
onOverrideModel: (model: AdminModel) => void;
}
export function ProviderCard({
provider,
isExpanded,
canShowBalance,
platformUrl,
isModelsLoading,
providerModels,
isDeletingModel,
onToggleExpansion,
onEditProvider,
onDeleteProvider,
onBatchOverride,
onAddModel,
onEditModel,
onDeleteModel,
onOverrideModel,
}: ProviderCardProps) {
const hasDetails = Boolean(provider.api_version) || isExpanded;
return (
<Card>
<CardHeader>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<CardTitle className='truncate'>
{provider.provider_type}
</CardTitle>
<Badge
variant={provider.enabled ? 'default' : 'secondary'}
className='w-fit'
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<CardDescription className='break-all'>
{provider.base_url}
</CardDescription>
</div>
<div className='grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center sm:justify-end'>
{canShowBalance && provider.api_key && (
<div className='col-span-2 sm:col-auto'>
<ProviderBalance
providerId={provider.id}
platformUrl={platformUrl}
/>
</div>
)}
<Button
variant='outline'
size='sm'
onClick={onToggleExpansion}
className='col-span-2 justify-between sm:col-auto sm:justify-center'
>
<span className='inline-flex items-center gap-1.5'>
<Database className='h-4 w-4' />
<span>Models</span>
</span>
{isExpanded ? (
<ChevronUp className='h-4 w-4' />
) : (
<ChevronDown className='h-4 w-4' />
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={onEditProvider}
className='justify-center gap-1.5'
>
<Pencil className='h-4 w-4' />
<span>Edit</span>
</Button>
<Button
variant='outline'
size='sm'
onClick={onDeleteProvider}
className='text-destructive hover:text-destructive justify-center gap-1.5'
>
<Trash2 className='h-4 w-4' />
<span>Delete</span>
</Button>
</div>
</div>
</CardHeader>
{hasDetails ? (
<CardContent>
<div className='space-y-3'>
{provider.api_version && (
<div className='flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between'>
<span className='text-muted-foreground'>API Version:</span>
<span className='font-mono break-all'>
{provider.api_version}
</span>
</div>
)}
{isExpanded && (
<div className='mt-3 border-t pt-3'>
<ProviderModelsPanel
isLoading={isModelsLoading}
providerModels={providerModels}
onBatchOverride={onBatchOverride}
onAddModel={onAddModel}
onEditModel={onEditModel}
onDeleteModel={onDeleteModel}
onOverrideModel={onOverrideModel}
isDeletingModel={isDeletingModel}
/>
</div>
)}
</div>
</CardContent>
) : null}
</Card>
);
}

View File

@@ -0,0 +1,88 @@
import type { Dispatch, SetStateAction } from 'react';
import type {
CreateUpstreamProvider,
ProviderType,
} from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ProviderFormFields } from '@/components/provider-form-fields';
interface ProviderFormDialogContentProps {
mode: 'create' | 'edit';
title: string;
description: string;
submitLabel: string;
submittingLabel: string;
formData: CreateUpstreamProvider;
setFormData: Dispatch<SetStateAction<CreateUpstreamProvider>>;
providerTypes: ProviderType[];
providerFeePlaceholder: string;
docsLinkClassName: string;
canCreateAccount: boolean;
isCreatingAccount: boolean;
onCreateAccount: () => void;
onCancel: () => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function ProviderFormDialogContent({
mode,
title,
description,
submitLabel,
submittingLabel,
formData,
setFormData,
providerTypes,
providerFeePlaceholder,
docsLinkClassName,
canCreateAccount,
isCreatingAccount,
onCreateAccount,
onCancel,
onSubmit,
isSubmitting,
}: ProviderFormDialogContentProps) {
return (
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<ProviderFormFields
mode={mode}
formData={formData}
setFormData={setFormData}
providerTypes={providerTypes}
providerFeePlaceholder={providerFeePlaceholder}
docsLinkClassName={docsLinkClassName}
canCreateAccount={canCreateAccount}
isCreatingAccount={isCreatingAccount}
onCreateAccount={onCreateAccount}
/>
<DialogFooter>
<Button
variant='outline'
onClick={onCancel}
className='w-full sm:w-auto'
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isSubmitting}
className='w-full sm:w-auto'
>
{isSubmitting ? submittingLabel : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import type { Dispatch, SetStateAction } from 'react';
import type {
CreateUpstreamProvider,
ProviderType,
} from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
interface ProviderFormFieldsProps {
mode: 'create' | 'edit';
formData: CreateUpstreamProvider;
setFormData: Dispatch<SetStateAction<CreateUpstreamProvider>>;
providerTypes: ProviderType[];
providerFeePlaceholder: string;
docsLinkClassName: string;
canCreateAccount: boolean;
isCreatingAccount: boolean;
onCreateAccount: () => void;
}
export function ProviderFormFields({
mode,
formData,
setFormData,
providerTypes,
providerFeePlaceholder,
docsLinkClassName,
canCreateAccount,
isCreatingAccount,
onCreateAccount,
}: ProviderFormFieldsProps) {
const idPrefix = mode === 'edit' ? 'edit_' : '';
const providerType = providerTypes.find(
(pt) => pt.id === formData.provider_type
);
const hasFixedBaseUrl = providerType?.fixed_base_url || false;
const platformUrl = providerType?.platform_url || null;
const isGenericType = (type: ProviderType) =>
type.id.toLowerCase() === 'generic';
const nonGenericTypes = providerTypes.filter((type) => !isGenericType(type));
const genericType = providerTypes.find((type) => isGenericType(type));
const apiKeyLabel =
mode === 'edit' ? 'API Key (leave blank to keep current)' : 'API Key';
const apiKeyPlaceholder =
mode === 'edit' ? 'Leave blank to keep current' : 'sk-...';
return (
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor={`${idPrefix}provider_type`}>Provider Type</Label>
<Select
value={formData.provider_type}
onValueChange={(value) => {
const selectedType = providerTypes.find((pt) => pt.id === value);
setFormData((prev) => ({
...prev,
provider_type: value,
base_url: selectedType?.default_base_url || '',
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
}));
}}
>
<SelectTrigger id={`${idPrefix}provider_type`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{nonGenericTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.name}
</SelectItem>
))}
{genericType ? (
<>
{nonGenericTypes.length > 0 ? (
<SelectSeparator className='my-1.5 opacity-70' />
) : null}
<SelectItem value={genericType.id} className='font-medium'>
Custom
</SelectItem>
</>
) : null}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor={`${idPrefix}base_url`}>Base URL</Label>
<Input
id={`${idPrefix}base_url`}
value={formData.base_url}
onChange={(e) =>
setFormData((prev) => ({ ...prev, base_url: e.target.value }))
}
placeholder='https://api.example.com/v1'
disabled={hasFixedBaseUrl}
className={hasFixedBaseUrl ? 'cursor-not-allowed opacity-60' : ''}
/>
</div>
<div className='grid gap-2'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<Label htmlFor={`${idPrefix}api_key`}>{apiKeyLabel}</Label>
{mode === 'create' && canCreateAccount ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={onCreateAccount}
disabled={isCreatingAccount}
className='h-6 w-full text-xs sm:w-auto'
>
{isCreatingAccount ? 'Creating...' : 'Create Account'}
</Button>
) : (
platformUrl && (
<a
href={platformUrl}
target='_blank'
rel='noopener noreferrer'
className={`${docsLinkClassName} break-all`}
>
Get Your API Key Here
</a>
)
)}
</div>
<Input
id={`${idPrefix}api_key`}
type='password'
value={formData.api_key}
onChange={(e) =>
setFormData((prev) => ({ ...prev, api_key: e.target.value }))
}
placeholder={apiKeyPlaceholder}
/>
</div>
{formData.provider_type === 'azure' && (
<div className='grid gap-2'>
<Label htmlFor={`${idPrefix}api_version`}>API Version</Label>
<Input
id={`${idPrefix}api_version`}
value={formData.api_version || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
api_version: e.target.value || null,
}))
}
placeholder='2024-02-15-preview'
/>
</div>
)}
<div className='flex items-center space-x-2'>
<Switch
id={`${idPrefix}enabled`}
checked={formData.enabled}
onCheckedChange={(checked) =>
setFormData((prev) => ({ ...prev, enabled: checked }))
}
/>
<Label htmlFor={`${idPrefix}enabled`}>Enabled</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor={`${idPrefix}provider_fee`}>
Provider Fee (Multiplier)
</Label>
<Input
id={`${idPrefix}provider_fee`}
type='number'
step='0.001'
min='1.0'
value={formData.provider_fee || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
provider_fee: e.target.value
? parseFloat(e.target.value)
: undefined,
}))
}
placeholder={providerFeePlaceholder}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Badge } from '@/components/ui/badge';
import type { AdminModel } from '@/lib/api/services/admin';
import type { ReactNode } from 'react';
interface ProviderModelRowProps {
model: AdminModel;
showEnabledState?: boolean;
actions?: ReactNode;
}
export function ProviderModelRow({
model,
showEnabledState = false,
actions,
}: ProviderModelRowProps) {
return (
<div className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='truncate font-mono text-sm font-medium'>
{model.id}
</span>
{showEnabledState && (
<Badge
variant={model.enabled ? 'default' : 'secondary'}
className='w-fit text-xs'
>
{model.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description || model.name}
</div>
</div>
<div className='flex flex-wrap items-center gap-2 sm:flex-nowrap'>
<span className='text-muted-foreground text-xs'>
{model.context_length
? `${model.context_length.toLocaleString()} tokens`
: '-'}
</span>
{actions}
</div>
</div>
);
}

View File

@@ -0,0 +1,168 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Database, Pencil, Plus, Trash2 } from 'lucide-react';
import type { AdminModel, ProviderModels } from '@/lib/api/services/admin';
import { ProviderModelRow } from '@/components/provider-model-row';
interface ProviderModelsPanelProps {
isLoading: boolean;
providerModels: ProviderModels | null;
onBatchOverride: () => void;
onAddModel: () => void;
onEditModel: (model: AdminModel) => void;
onDeleteModel: (modelId: string) => void;
onOverrideModel: (model: AdminModel) => void;
isDeletingModel: boolean;
}
export function ProviderModelsPanel({
isLoading,
providerModels,
onBatchOverride,
onAddModel,
onEditModel,
onDeleteModel,
onOverrideModel,
isDeletingModel,
}: ProviderModelsPanelProps) {
if (isLoading) {
return (
<div className='space-y-2'>
<Skeleton className='h-[40px] w-full' />
<Skeleton className='h-[40px] w-full' />
</div>
);
}
if (!providerModels) {
return null;
}
return (
<Tabs
defaultValue={
providerModels.remote_models.length > 0 ? 'provided' : 'custom'
}
className='w-full'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='provided' className='text-xs sm:text-sm'>
<span className='hidden sm:inline'>Provided Models</span>
<span className='sm:hidden'>Provided</span>
<Badge variant='secondary' className='ml-1 text-xs sm:ml-2'>
{providerModels.remote_models.length}
</Badge>
</TabsTrigger>
<TabsTrigger value='custom' className='text-xs sm:text-sm'>
<span className='hidden sm:inline'>Custom Models</span>
<span className='sm:hidden'>Custom</span>
<Badge variant='secondary' className='ml-1 text-xs sm:ml-2'>
{providerModels.db_models.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value='custom' className='mt-4 space-y-2'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
{providerModels.db_models.length > 0 && (
<p className='text-muted-foreground text-sm'>
Custom models override or extend the provider&apos;s catalog.
</p>
)}
<div className='flex w-full flex-wrap gap-2 sm:w-auto sm:justify-end'>
<Button
variant='outline'
size='sm'
onClick={onBatchOverride}
className='w-full sm:w-auto'
>
<Database className='mr-2 h-4 w-4' />
Batch Override
</Button>
<Button
variant='outline'
size='sm'
onClick={onAddModel}
className='w-full sm:w-auto'
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
</div>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
</div>
) : (
<div className='space-y-2'>
{providerModels.db_models.map((model) => (
<ProviderModelRow
key={model.id}
model={model}
showEnabledState
actions={
<>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onEditModel(model)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
className='text-destructive hover:text-destructive h-8 w-8'
onClick={() => onDeleteModel(model.id)}
disabled={isDeletingModel}
>
<Trash2 className='h-4 w-4' />
</Button>
</>
}
/>
))}
</div>
)}
</TabsContent>
<TabsContent value='provided' className='mt-4 space-y-2'>
{providerModels.remote_models.length > 0 ? (
<>
<p className='text-muted-foreground mb-3 text-sm'>
Models automatically discovered from the provider&apos;s catalog.
</p>
<div className='space-y-2'>
{providerModels.remote_models.map((model) => (
<ProviderModelRow
key={model.id}
model={model}
actions={
<Button
variant='outline'
size='sm'
className='h-7 w-full text-xs sm:w-auto'
onClick={() => onOverrideModel(model)}
>
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
}
/>
))}
</div>
</>
) : (
<div className='text-muted-foreground py-4 text-center text-sm'>
No provided models available
</div>
)}
</TabsContent>
</Tabs>
);
}

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