mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
56 Commits
update-doc
...
ui-update-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20b4c3a641 | ||
|
|
88f7ee734f | ||
|
|
326a0086b7 | ||
|
|
eb46651373 | ||
|
|
226188e22d | ||
|
|
89fc48eea2 | ||
|
|
f66d98e6b5 | ||
|
|
cfea4f9cd1 | ||
|
|
c74a877ab5 | ||
|
|
83a76e86fe | ||
|
|
5788d63892 | ||
|
|
d0c7cc6bd9 | ||
|
|
40096867d9 | ||
|
|
e002c0b66f | ||
|
|
608549d051 | ||
|
|
72f389c8d2 | ||
|
|
4bce04ada4 | ||
|
|
f1e2448620 | ||
|
|
28340a152c | ||
|
|
e28f6118e7 | ||
|
|
9fb6f54d12 | ||
|
|
3cb8d7b5dd | ||
|
|
ede076b881 | ||
|
|
449a0951f9 | ||
|
|
8c9ede2272 | ||
|
|
e794b09614 | ||
|
|
24f6519267 | ||
|
|
f57beb6411 | ||
|
|
3e41e59a1d | ||
|
|
002d750830 | ||
|
|
8c2eb55760 | ||
|
|
6fbd479bdc | ||
|
|
f67c26935a | ||
|
|
9ce91f58a9 | ||
|
|
bb82361434 | ||
|
|
5f1d67e87e | ||
|
|
b2106ad1e6 | ||
|
|
709a4ba0dc | ||
|
|
21f421b212 | ||
|
|
b3c5e4cbf6 | ||
|
|
1d95379328 | ||
|
|
48529f672a | ||
|
|
030c2f65e4 | ||
|
|
3510402af2 | ||
|
|
5f376d716d | ||
|
|
d889274f84 | ||
|
|
0c0f19d854 | ||
|
|
b61bffc666 | ||
|
|
af658136d4 | ||
|
|
8973627b5f | ||
|
|
52c7f17215 | ||
|
|
2c03302055 | ||
|
|
c3221f2a31 | ||
|
|
ce9834d7ec | ||
|
|
6ebe73f2f7 | ||
|
|
98aecb08f9 |
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -38,3 +38,8 @@ proof_backups
|
||||
|
||||
*.todo
|
||||
ui_out
|
||||
output/
|
||||
.pnpm-store/
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
@@ -360,6 +360,42 @@ POST /v1/wallet/create
|
||||
}
|
||||
```
|
||||
|
||||
### Get Key Information
|
||||
|
||||
Get current balance, consumption data, and child keys for an API key.
|
||||
|
||||
```http
|
||||
GET /v1/balance/info
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-abc...",
|
||||
"balance": 8500000,
|
||||
"reserved": 0,
|
||||
"is_child": false,
|
||||
"parent_key": null,
|
||||
"total_requests": 42,
|
||||
"total_spent": 1500000,
|
||||
"balance_limit": null,
|
||||
"balance_limit_reset": null,
|
||||
"validity_date": null,
|
||||
"child_keys": [
|
||||
{
|
||||
"api_key": "sk-child1...",
|
||||
"total_requests": 10,
|
||||
"total_spent": 500000,
|
||||
"balance_limit": 1000000,
|
||||
"balance_limit_reset": "daily",
|
||||
"validity_date": 1738000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current wallet balance.
|
||||
|
||||
@@ -6,6 +6,32 @@ For automated deployments, you can optionally pre-configure settings via environ
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup (.env file)
|
||||
|
||||
Before running your node, you should create a `.env` file in the project root. This file is used to bootstrap the initial configuration and store sensitive secrets.
|
||||
|
||||
### Example .env
|
||||
|
||||
```bash
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# Node Identity
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
|
||||
# Lightning Payouts
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
```
|
||||
|
||||
### Setting the UI Password
|
||||
|
||||
There are two ways to set or change your Admin Dashboard password:
|
||||
|
||||
1. **Via Environment Variable**: Set `ADMIN_PASSWORD` in your `.env` file before starting the container. This will be the password used for the first login.
|
||||
2. **Via Dashboard**: Once logged in, go to **Settings** → **Security** to update your password. Dashboard settings override the `.env` file once saved.
|
||||
|
||||
---
|
||||
|
||||
## Admin Dashboard (Primary)
|
||||
|
||||
Access the dashboard at `/admin/` on your node.
|
||||
@@ -14,29 +40,29 @@ Access the dashboard at `/admin/` on your node.
|
||||
|
||||
Connect to your AI provider(s):
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Setting | Description |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
|
||||
| **API Key** | Your provider's API key |
|
||||
| **API Key** | Your provider's API key |
|
||||
|
||||
### Node Identity
|
||||
|
||||
How your node appears to clients:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
|
||||
| **Description** | Brief description of your service |
|
||||
| Setting | Description |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
|
||||
| **Description** | Brief description of your service |
|
||||
|
||||
### Pricing
|
||||
|
||||
Control your profit margins:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
|
||||
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
|
||||
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
|
||||
| Setting | Description | Default |
|
||||
| ----------------- | ------------------------------------------ | ------------ |
|
||||
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
|
||||
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
|
||||
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
|
||||
|
||||
See [Pricing](pricing.md) for detailed strategies.
|
||||
|
||||
@@ -44,33 +70,33 @@ See [Pricing](pricing.md) for detailed strategies.
|
||||
|
||||
Which mints to accept payments from:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Setting | Description |
|
||||
| --------- | ------------------------------- |
|
||||
| **Mints** | List of trusted Cashu mint URLs |
|
||||
|
||||
### Lightning Withdrawals
|
||||
|
||||
Automatic profit withdrawal:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Setting | Description |
|
||||
| --------------------- | ------------------------------- |
|
||||
| **Lightning Address** | Your LN address for withdrawals |
|
||||
|
||||
### Security
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Setting | Description |
|
||||
| ------------------ | ----------------------------- |
|
||||
| **Admin Password** | Password for dashboard access |
|
||||
|
||||
### Nostr Discovery
|
||||
|
||||
Announce your node on the network:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Npub** | Your Nostr public key |
|
||||
| **Nsec** | Your Nostr private key (for signing) |
|
||||
| **Relays** | Relays to publish announcements |
|
||||
| Setting | Description |
|
||||
| ---------- | ------------------------------------ |
|
||||
| **Npub** | Your Nostr public key |
|
||||
| **Nsec** | Your Nostr private key (for signing) |
|
||||
| **Relays** | Relays to publish announcements |
|
||||
|
||||
See [Discovery](discovery.md) for details.
|
||||
|
||||
@@ -86,21 +112,21 @@ Use environment variables for:
|
||||
|
||||
### All Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
|
||||
| `UPSTREAM_API_KEY` | Upstream API key | — |
|
||||
| `ADMIN_PASSWORD` | Dashboard password | (none) |
|
||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
|
||||
| `NAME` | Node display name | `ARoutstrNode` |
|
||||
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
||||
| `NPUB` | Nostr public key (bech32) | — |
|
||||
| `NSEC` | Nostr private key | — |
|
||||
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
||||
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
|
||||
| Variable | Description | Default |
|
||||
| -------------------- | --------------------------------- | ------------------------------------ |
|
||||
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
|
||||
| `UPSTREAM_API_KEY` | Upstream API key | — |
|
||||
| `ADMIN_PASSWORD` | Dashboard password | (none) |
|
||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
|
||||
| `NAME` | Node display name | `ARoutstrNode` |
|
||||
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
||||
| `NPUB` | Nostr public key (bech32) | — |
|
||||
| `NSEC` | Nostr private key | — |
|
||||
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
||||
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
|
||||
|
||||
### Priority
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]] = {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1380
routstr/core/usage_analytics_store.py
Normal file
1380
routstr/core/usage_analytics_store.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
|
||||
class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
@@ -58,19 +59,52 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def prepare_headers(self, request_headers: dict) -> dict:
|
||||
"""Prepare headers for Azure OpenAI, adding api-key."""
|
||||
headers = super().prepare_headers(request_headers)
|
||||
if self.api_key:
|
||||
headers["api-key"] = self.api_key
|
||||
headers.pop("Authorization", None)
|
||||
headers.pop("authorization", None)
|
||||
return headers
|
||||
|
||||
def prepare_params(
|
||||
self, path: str, query_params: Mapping[str, str] | None
|
||||
) -> Mapping[str, str]:
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version.
|
||||
|
||||
Args:
|
||||
path: Request path
|
||||
query_params: Original query parameters from the client
|
||||
|
||||
Returns:
|
||||
Query parameters dict with Azure API version added for chat completions
|
||||
"""
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version."""
|
||||
params = dict(query_params or {})
|
||||
if path.endswith("chat/completions"):
|
||||
params["api-version"] = self.api_version
|
||||
version = (self.api_version or "").replace("\ufeff", "").strip()
|
||||
if not version or version.lower() == "v1":
|
||||
version = "2024-02-15-preview"
|
||||
params["api-version"] = version
|
||||
return params
|
||||
|
||||
def normalize_request_path(
|
||||
self, path: str, model_obj: "Model | None" = None
|
||||
) -> str:
|
||||
"""Build Azure deployment-specific request path."""
|
||||
clean_path = super().normalize_request_path(path, model_obj).lstrip("/")
|
||||
if model_obj is None:
|
||||
return clean_path
|
||||
|
||||
deployment_id = getattr(
|
||||
model_obj, "canonical_slug", None
|
||||
) or self.transform_model_name(model_obj.id)
|
||||
deployment_id = deployment_id.split("/")[-1]
|
||||
return f"openai/deployments/{deployment_id}/{clean_path}"
|
||||
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: "Model | None" = None
|
||||
) -> str:
|
||||
"""Use endpoint root, stripping accidental /openai/v1 suffix if present."""
|
||||
base_url = self.base_url.rstrip("/")
|
||||
marker = "/openai/v1"
|
||||
if marker in base_url:
|
||||
base_url = base_url.split(marker, 1)[0].rstrip("/")
|
||||
return base_url
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Extract deployment name from model ID."""
|
||||
if "/" in model_id:
|
||||
return model_id.split("/")[-1]
|
||||
return model_id
|
||||
|
||||
@@ -5,21 +5,18 @@ import json
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Mapping
|
||||
from typing import Mapping
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from ..auth import adjust_payment_for_tokens, revert_pay_for_request
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core import get_logger
|
||||
from ..core.db import ApiKey, AsyncSession, create_session
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session
|
||||
from ..core.exceptions import UpstreamError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
from ..payment.cost_calculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
@@ -32,6 +29,7 @@ from ..payment.models import (
|
||||
Pricing,
|
||||
_calculate_usd_max_costs,
|
||||
_update_model_sats_pricing,
|
||||
list_models,
|
||||
)
|
||||
from ..payment.price import sats_usd_price
|
||||
from ..wallet import recieve_token, send_token
|
||||
@@ -199,6 +197,23 @@ class BaseUpstreamProvider:
|
||||
"""
|
||||
return model_id
|
||||
|
||||
def normalize_request_path(self, path: str, model_obj: Model | None = None) -> str:
|
||||
"""Normalize request path before forwarding to upstream."""
|
||||
if path.startswith("v1/"):
|
||||
return path.replace("v1/", "", 1)
|
||||
return path
|
||||
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: Model | None = None
|
||||
) -> str:
|
||||
"""Get upstream base URL used when building forwarding URL."""
|
||||
return self.base_url.rstrip("/")
|
||||
|
||||
def build_request_url(self, path: str, model_obj: Model | None = None) -> str:
|
||||
"""Build full upstream URL from normalized path."""
|
||||
clean_path = path.lstrip("/")
|
||||
return f"{self.get_request_base_url(path, model_obj)}/{clean_path}"
|
||||
|
||||
def prepare_responses_request_body(
|
||||
self, body: bytes | None, model_obj: Model
|
||||
) -> bytes | None:
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -205,6 +205,9 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if provider:
|
||||
# Keep provider DB id on runtime instance so model mapping can
|
||||
# bind DB overrides to the correct upstream.
|
||||
setattr(provider, "db_id", provider_row.id)
|
||||
await provider.refresh_models_cache()
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
|
||||
@@ -3,13 +3,11 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
@@ -67,38 +65,11 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Strip 'ollama/' prefix for Ollama API compatibility."""
|
||||
return model_id.removeprefix("ollama/")
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Override to use OpenAI-compatible endpoint for proxy requests."""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
original_base_url = self.base_url
|
||||
self.base_url = f"{self.base_url}/v1"
|
||||
|
||||
try:
|
||||
result = await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
self.base_url = original_base_url
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: Model | None = None
|
||||
) -> str:
|
||||
"""Route proxy traffic through Ollama's OpenAI-compatible /v1 endpoint."""
|
||||
return f"{self.base_url.rstrip('/')}/v1"
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from Ollama API using /api/tags endpoint."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
97
tests/integration/test_child_keys_api.py
Normal file
97
tests/integration/test_child_keys_api.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_returns_child_keys(
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
"""Test that GET /v1/wallet/info returns child keys for a parent key"""
|
||||
|
||||
# 1. Get parent info to find its hashed_key
|
||||
response = await authenticated_client.get("/v1/wallet/info")
|
||||
assert response.status_code == 200
|
||||
parent_data = response.json()
|
||||
parent_data["api_key"]
|
||||
|
||||
# 2. Create child keys for this parent
|
||||
# We need to use the parent's authentication for this
|
||||
child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"}
|
||||
create_response = await authenticated_client.post(
|
||||
"/v1/wallet/child-key", json=child_payload
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
create_data = create_response.json()
|
||||
child_keys = create_data["api_keys"]
|
||||
assert len(child_keys) == 2
|
||||
|
||||
# 3. Call /info again and check for child_keys
|
||||
info_response = await authenticated_client.get("/v1/wallet/info")
|
||||
assert info_response.status_code == 200
|
||||
info_data = info_response.json()
|
||||
|
||||
assert "child_keys" in info_data
|
||||
assert len(info_data["child_keys"]) == 2
|
||||
|
||||
# Verify child key details
|
||||
for ck in info_data["child_keys"]:
|
||||
assert ck["api_key"] in child_keys
|
||||
assert ck["balance_limit"] == 1000
|
||||
assert ck["balance_limit_reset"] == "daily"
|
||||
assert "total_spent" in ck
|
||||
assert "total_requests" in ck
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_child_key_no_child_keys(
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
"""Test that GET /v1/wallet/info for a child key does NOT return child_keys"""
|
||||
|
||||
# 1. Create a child key
|
||||
child_payload = {"count": 1}
|
||||
create_response = await authenticated_client.post(
|
||||
"/v1/wallet/child-key", json=child_payload
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
child_key = create_response.json()["api_keys"][0]
|
||||
|
||||
# 2. Use the child key to get its info
|
||||
integration_client.headers["Authorization"] = f"Bearer {child_key}"
|
||||
info_response = await integration_client.get("/v1/wallet/info")
|
||||
assert info_response.status_code == 200
|
||||
info_data = info_response.json()
|
||||
|
||||
assert info_data["is_child"] is True
|
||||
assert "child_keys" not in info_data
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_info_root_returns_child_keys(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test that GET / returns child keys for a parent key (root endpoint)"""
|
||||
|
||||
# 1. Create a child key
|
||||
child_payload = {"count": 1}
|
||||
await authenticated_client.post("/v1/wallet/child-key", json=child_payload)
|
||||
|
||||
# 2. Call root endpoint /v1/balance/
|
||||
# Note: routstr/balance.py defines router = APIRouter()
|
||||
# and it is included in balance_router with prefix /v1/balance
|
||||
# The endpoint is @router.get("/")
|
||||
response = await authenticated_client.get("/v1/balance/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "child_keys" in data
|
||||
assert len(data["child_keys"]) >= 1
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
104
tests/unit/test_model_pricing_units.py
Normal file
104
tests/unit/test_model_pricing_units.py
Normal 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
|
||||
82
tests/unit/test_upstream_azure.py
Normal file
82
tests/unit/test_upstream_azure.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Tests for Azure upstream provider request normalization."""
|
||||
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
from routstr.upstream.azure import AzureUpstreamProvider
|
||||
|
||||
|
||||
def create_test_model(model_id: str, canonical_slug: str | None = None) -> Model:
|
||||
return Model(
|
||||
id=model_id,
|
||||
name=model_id,
|
||||
created=0,
|
||||
description="test",
|
||||
context_length=8192,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="test",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.001,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
canonical_slug=canonical_slug,
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_headers_uses_azure_api_key_header() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
|
||||
headers = provider.prepare_headers({"Authorization": "Bearer user-token"})
|
||||
|
||||
assert headers["api-key"] == "azure-key"
|
||||
assert "Authorization" not in headers
|
||||
assert "authorization" not in headers
|
||||
|
||||
|
||||
def test_prepare_params_normalizes_azure_api_version() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com",
|
||||
api_key="azure-key",
|
||||
api_version="\ufeff v1 ",
|
||||
)
|
||||
|
||||
params = provider.prepare_params("chat/completions", {})
|
||||
|
||||
assert params["api-version"] == "2024-02-15-preview"
|
||||
|
||||
|
||||
def test_normalize_request_path_includes_deployment_id() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com/openai/v1",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
model = create_test_model("azure/gpt-4o", canonical_slug="deploy-gpt4o")
|
||||
|
||||
path = provider.normalize_request_path("v1/chat/completions", model)
|
||||
|
||||
assert path == "openai/deployments/deploy-gpt4o/chat/completions"
|
||||
|
||||
|
||||
def test_get_request_base_url_strips_openai_v1_suffix() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com/openai/v1",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
model = create_test_model("azure/gpt-4o")
|
||||
|
||||
base_url = provider.get_request_base_url("chat/completions", model)
|
||||
|
||||
assert base_url == "https://example.openai.azure.com"
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next",
|
||||
"next/core-web-vitals",
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"plugins": ["react", "@typescript-eslint"]
|
||||
}
|
||||
1
ui/.gitignore
vendored
1
ui/.gitignore
vendored
@@ -42,3 +42,4 @@ next-env.d.ts
|
||||
|
||||
# favicon conflicts
|
||||
/app/favicon.ico
|
||||
.pnpm-store/
|
||||
|
||||
5
ui/.prettierignore
Normal file
5
ui/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
pnpm-lock.yaml
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
next-env.d.ts
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
import { useCurrencyStore } from '@/lib/stores/currency';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
|
||||
import { TemporaryBalances } from '@/components/temporary-balances';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
|
||||
export default function BalancesPage() {
|
||||
const { displayUnit } = useCurrencyStore();
|
||||
@@ -22,39 +21,26 @@ export default function BalancesPage() {
|
||||
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor and manage wallet balances
|
||||
</p>
|
||||
</div>
|
||||
{/* Global currency toggle is now in SiteHeader */}
|
||||
</div>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='Balances'
|
||||
description='Monitor and manage wallet balances across cashu mints and temporary stores.'
|
||||
/>
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<div className='col-span-full'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-6'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,133 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:is(.dark *, .red *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--font-geist-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
|
||||
Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
.red {
|
||||
--background: oklch(0.14 0.03 24);
|
||||
--foreground: oklch(0.9 0.05 28);
|
||||
--card: oklch(0.17 0.04 24);
|
||||
--card-foreground: oklch(0.9 0.05 28);
|
||||
--popover: oklch(0.17 0.04 24);
|
||||
--popover-foreground: oklch(0.9 0.05 28);
|
||||
--primary: oklch(0.78 0.14 25);
|
||||
--primary-foreground: oklch(0.14 0.03 24);
|
||||
--secondary: oklch(0.22 0.05 24);
|
||||
--secondary-foreground: oklch(0.9 0.05 28);
|
||||
--muted: oklch(0.22 0.05 24);
|
||||
--muted-foreground: oklch(0.72 0.04 26);
|
||||
--accent: oklch(0.25 0.08 25);
|
||||
--accent-foreground: oklch(0.92 0.05 28);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(0.72 0.11 25 / 24%);
|
||||
--input: oklch(0.75 0.12 25 / 28%);
|
||||
--ring: oklch(0.62 0.12 24);
|
||||
}
|
||||
|
||||
html.red {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-border: var(--border);
|
||||
--color-card: var(--card);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -21,194 +141,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;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Routstr',
|
||||
description: 'Routstr model management',
|
||||
@@ -34,7 +21,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<SuppressHydrationWarning>
|
||||
<Providers>{children}</Providers>
|
||||
|
||||
@@ -5,16 +5,10 @@ import type { ChangeEvent, FormEvent, ReactElement } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { adminLogin } from '@/lib/api/services/auth';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { toast } from 'sonner';
|
||||
import { AuthPageShell } from '@/components/auth-page-shell';
|
||||
|
||||
export default function AdminLoginPage(): ReactElement {
|
||||
const router = useRouter();
|
||||
@@ -78,51 +72,42 @@ export default function AdminLoginPage(): ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
|
||||
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='text-center text-2xl font-bold'>
|
||||
Admin Login
|
||||
</CardTitle>
|
||||
<CardDescription className='text-center'>
|
||||
Enter your admin password to access the dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{allowCustomBaseUrl && (
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='API URL (https://api.example.com)'
|
||||
value={baseUrl}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setBaseUrl(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Admin Password'
|
||||
value={password}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<AuthPageShell
|
||||
title='Admin Login'
|
||||
description='Enter your admin password to access the dashboard.'
|
||||
>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{allowCustomBaseUrl && (
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='API URL (https://api.example.com)'
|
||||
value={baseUrl}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setBaseUrl(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Admin Password'
|
||||
value={password}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,18 +10,8 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface LogEntry {
|
||||
asctime: string;
|
||||
name: string;
|
||||
levelname: string;
|
||||
message: string;
|
||||
pathname: string;
|
||||
lineno: number;
|
||||
version: string;
|
||||
request_id: string;
|
||||
[key: string]: string | number | object | undefined;
|
||||
}
|
||||
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogDetailsDialogProps {
|
||||
log: LogEntry | null;
|
||||
@@ -29,24 +19,6 @@ interface LogDetailsDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogDetailsDialog({
|
||||
log,
|
||||
isOpen,
|
||||
@@ -79,10 +51,13 @@ export function LogDetailsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
|
||||
<DialogContent className='max-h-[92svh] w-full max-w-none overflow-hidden md:max-w-4xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className={getLevelColor(log.levelname)}>
|
||||
<Badge
|
||||
variant={getLogLevelBadgeVariant(log.levelname)}
|
||||
className='uppercase'
|
||||
>
|
||||
{log.levelname}
|
||||
</Badge>
|
||||
<span>Log Entry Details</span>
|
||||
@@ -92,7 +67,7 @@ export function LogDetailsDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
|
||||
<ScrollArea className='h-[70svh] w-full overflow-x-auto sm:h-[75vh]'>
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
interface LogEntry {
|
||||
asctime: string;
|
||||
name: string;
|
||||
levelname: string;
|
||||
message: string;
|
||||
pathname: string;
|
||||
lineno: number;
|
||||
version: string;
|
||||
request_id: string;
|
||||
[key: string]: string | number | object | undefined;
|
||||
}
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogEntryCardProps {
|
||||
entry: LogEntry;
|
||||
onClick: (entry: LogEntry) => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
|
||||
const extraFields = Object.keys(entry).filter(
|
||||
(key) =>
|
||||
@@ -50,73 +22,58 @@ export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
|
||||
'request_id',
|
||||
].includes(key)
|
||||
);
|
||||
const hasRequestId =
|
||||
Boolean(entry.request_id) && entry.request_id !== 'no-request-id';
|
||||
const shortPath = entry.pathname.split('/').pop() || entry.pathname;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
|
||||
className='bg-card hover:bg-accent/35 group mb-2 cursor-pointer rounded-lg border p-2.5 transition-colors duration-150 sm:p-3'
|
||||
onClick={() => onClick(entry)}
|
||||
>
|
||||
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline' className={getLevelColor(entry.levelname)}>
|
||||
{entry.levelname}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
|
||||
{entry.asctime}
|
||||
</span>
|
||||
<Badge variant='secondary' className='truncate text-xs'>
|
||||
{entry.name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<div className='text-muted-foreground truncate text-xs'>
|
||||
{entry.pathname}:{entry.lineno}
|
||||
</div>
|
||||
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
|
||||
{entry.message}
|
||||
</div>
|
||||
|
||||
{entry.request_id && entry.request_id !== 'no-request-id' && (
|
||||
<div className='mb-2 min-w-0'>
|
||||
<div className='inline-block max-w-full'>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
|
||||
Request ID: {entry.request_id}
|
||||
</span>
|
||||
<div className='flex min-w-0 items-start justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1 space-y-1.5'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
|
||||
<Badge
|
||||
variant={getLogLevelBadgeVariant(entry.levelname)}
|
||||
className='h-5 px-1.5 text-[10px] uppercase'
|
||||
>
|
||||
{entry.levelname}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground text-[11px]'>
|
||||
{entry.asctime}
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-5 max-w-[11rem] truncate px-1.5 text-[10px]'
|
||||
>
|
||||
{entry.name}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extraFields.length > 0 && (
|
||||
<div className='mt-3 min-w-0 border-t pt-3'>
|
||||
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
|
||||
<div className='grid grid-cols-1 gap-2'>
|
||||
{extraFields.slice(0, 4).map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className='min-w-0 overflow-hidden text-xs break-words'
|
||||
>
|
||||
<span className='font-medium break-all'>{key}:</span>{' '}
|
||||
<span className='text-muted-foreground break-all'>
|
||||
{typeof entry[key] === 'object'
|
||||
? JSON.stringify(entry[key])
|
||||
: String(entry[key])}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{extraFields.length > 4 && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
...and {extraFields.length - 4} more fields
|
||||
</div>
|
||||
)}
|
||||
<p className='line-clamp-1 font-mono text-xs break-words sm:text-sm'>
|
||||
{entry.message}
|
||||
</p>
|
||||
|
||||
<div className='text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-[11px]'>
|
||||
{hasRequestId ? (
|
||||
<span className='inline-block max-w-[14rem] truncate rounded border px-1.5 py-0.5 font-mono text-[10px] sm:max-w-[20rem]'>
|
||||
{entry.request_id}
|
||||
</span>
|
||||
) : null}
|
||||
<span className='truncate'>
|
||||
{shortPath}:{entry.lineno}
|
||||
</span>
|
||||
{extraFields.length > 0 ? (
|
||||
<span>{extraFields.length} extra</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='pt-0.5'>
|
||||
<ChevronRight className='text-muted-foreground h-4 w-4 opacity-50 transition-opacity group-hover:opacity-90' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -21,20 +29,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MultiSelectCommandFilter } from './multi-select-command-filter';
|
||||
|
||||
interface LogFiltersProps {
|
||||
selectedDate: string;
|
||||
@@ -97,32 +93,12 @@ const ENDPOINT_OPTIONS = [
|
||||
'/embeddings/models',
|
||||
];
|
||||
|
||||
interface FilterBadgeProps {
|
||||
value: string;
|
||||
onRemove: (value: string) => void;
|
||||
}
|
||||
|
||||
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='flex items-center gap-1 px-1 font-normal'
|
||||
>
|
||||
{value}
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove(value);
|
||||
}}
|
||||
className='hover:bg-muted-foreground/20 rounded-full'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const STATUS_4XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
|
||||
code.startsWith('4')
|
||||
);
|
||||
const STATUS_5XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
|
||||
code.startsWith('5')
|
||||
);
|
||||
|
||||
export function LogFilters({
|
||||
selectedDate,
|
||||
@@ -151,7 +127,7 @@ export function LogFilters({
|
||||
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
|
||||
const [date, setDate] = useState<Date | undefined>(
|
||||
selectedDate && selectedDate !== 'all'
|
||||
? new Date(selectedDate + 'T00:00:00')
|
||||
? new Date(`${selectedDate}T00:00:00`)
|
||||
: undefined
|
||||
);
|
||||
|
||||
@@ -162,6 +138,7 @@ export function LogFilters({
|
||||
useEffect(() => {
|
||||
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
||||
setIsCustom(!currentIsPreset);
|
||||
|
||||
if (!currentIsPreset) {
|
||||
setCustomLimit(limit.toString());
|
||||
}
|
||||
@@ -170,88 +147,79 @@ export function LogFilters({
|
||||
useEffect(() => {
|
||||
if (selectedDate === 'all' || !selectedDate) {
|
||||
setDate(undefined);
|
||||
} else {
|
||||
const d = new Date(selectedDate + 'T00:00:00');
|
||||
setDate(isNaN(d.getTime()) ? undefined : d);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(`${selectedDate}T00:00:00`);
|
||||
setDate(Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate);
|
||||
}, [selectedDate]);
|
||||
|
||||
const handleLimitChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setIsCustom(true);
|
||||
setCustomLimit(limit.toString());
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(Number(value));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(Number(value));
|
||||
};
|
||||
|
||||
const handleCustomLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setCustomLimit(value);
|
||||
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomLimit(event.target.value);
|
||||
};
|
||||
|
||||
const handleCustomLimitApply = () => {
|
||||
const numValue = parseInt(customLimit);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
onLimitChange(numValue);
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(100);
|
||||
const numericValue = Number.parseInt(customLimit, 10);
|
||||
|
||||
if (!Number.isNaN(numericValue) && numericValue > 0) {
|
||||
onLimitChange(numericValue);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(100);
|
||||
};
|
||||
|
||||
const handleCustomLimitKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === 'Enter') {
|
||||
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleCustomLimitApply();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
setDate(selectedDate);
|
||||
if (selectedDate) {
|
||||
onDateChange(format(selectedDate, 'yyyy-MM-dd'));
|
||||
} else {
|
||||
onDateChange('all');
|
||||
}
|
||||
};
|
||||
const handleDateSelect = (nextDate: Date | undefined) => {
|
||||
setDate(nextDate);
|
||||
|
||||
const toggleSelection = (
|
||||
current: string[],
|
||||
value: string,
|
||||
onChange: (val: string[]) => void
|
||||
) => {
|
||||
if (current.includes(value)) {
|
||||
onChange(current.filter((v) => v !== value));
|
||||
} else {
|
||||
onChange([...current, value]);
|
||||
if (nextDate) {
|
||||
onDateChange(format(nextDate, 'yyyy-MM-dd'));
|
||||
return;
|
||||
}
|
||||
|
||||
onDateChange('all');
|
||||
};
|
||||
|
||||
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
|
||||
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
|
||||
const newSelection = new Set([...selectedStatusCodes]);
|
||||
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
|
||||
const rangeCodes = range === '4xx' ? STATUS_4XX_CODES : STATUS_5XX_CODES;
|
||||
const nextSelection = new Set(selectedStatusCodes);
|
||||
const allSelected = rangeCodes.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
);
|
||||
|
||||
if (allIncluded) {
|
||||
codes.forEach((c) => newSelection.delete(c));
|
||||
if (allSelected) {
|
||||
rangeCodes.forEach((code) => nextSelection.delete(code));
|
||||
} else {
|
||||
codes.forEach((c) => newSelection.add(c));
|
||||
rangeCodes.forEach((code) => nextSelection.add(code));
|
||||
}
|
||||
onStatusCodesChange(Array.from(newSelection));
|
||||
|
||||
onStatusCodesChange(Array.from(nextSelection));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Filter className='h-5 w-5' />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>
|
||||
Filter logs by date, level, request ID, text search, status code,
|
||||
method, endpoint and limit
|
||||
@@ -314,336 +282,62 @@ export function LogFilters({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>Status Codes</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selectedStatusCodes.length > 0 ? (
|
||||
selectedStatusCodes.map((code) => (
|
||||
<FilterBadge
|
||||
key={code}
|
||||
value={code}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
val,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>All codes</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-64 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add status code...'
|
||||
value={statusSearch}
|
||||
onValueChange={setStatusSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedStatusCodes.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedStatusCodes.map((code) => (
|
||||
<CommandItem
|
||||
key={`selected-${code}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
code,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{code}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{statusSearch &&
|
||||
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
|
||||
!selectedStatusCodes.includes(statusSearch) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
if (/^\d+$/.test(statusSearch)) {
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
statusSearch,
|
||||
onStatusCodesChange
|
||||
);
|
||||
setStatusSearch('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{statusSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading='Quick Filters'>
|
||||
<CommandItem
|
||||
onSelect={() => handleQuickStatusCode('4xx')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||
c.startsWith('4')
|
||||
).every((c) => selectedStatusCodes.includes(c))}
|
||||
className='mr-2'
|
||||
/>
|
||||
4xx Errors
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => handleQuickStatusCode('5xx')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||
c.startsWith('5')
|
||||
).every((c) => selectedStatusCodes.includes(c))}
|
||||
className='mr-2'
|
||||
/>
|
||||
5xx Errors
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading='Common Codes'>
|
||||
{STATUS_CODE_OPTIONS.filter(
|
||||
(code) => !selectedStatusCodes.includes(code)
|
||||
).map((code) => (
|
||||
<CommandItem
|
||||
key={code}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
code,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{code}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='Status Codes'
|
||||
emptyLabel='All codes'
|
||||
selectedValues={selectedStatusCodes}
|
||||
onSelectedValuesChange={onStatusCodesChange}
|
||||
options={STATUS_CODE_OPTIONS}
|
||||
searchValue={statusSearch}
|
||||
onSearchValueChange={setStatusSearch}
|
||||
searchPlaceholder='Search or add status code...'
|
||||
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Common Codes'
|
||||
canAddCustom={(value) => /^\d+$/.test(value)}
|
||||
quickFilters={[
|
||||
{
|
||||
label: '4xx Errors',
|
||||
checked: STATUS_4XX_CODES.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
),
|
||||
onSelect: () => handleQuickStatusCode('4xx'),
|
||||
},
|
||||
{
|
||||
label: '5xx Errors',
|
||||
checked: STATUS_5XX_CODES.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
),
|
||||
onSelect: () => handleQuickStatusCode('5xx'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>HTTP Methods</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selectedMethods.length > 0 ? (
|
||||
selectedMethods.map((method) => (
|
||||
<FilterBadge
|
||||
key={method}
|
||||
value={method}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
val,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>All methods</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-64 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add method...'
|
||||
value={methodSearch}
|
||||
onValueChange={setMethodSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedMethods.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedMethods.map((method) => (
|
||||
<CommandItem
|
||||
key={`selected-${method}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
method,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{method}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{methodSearch &&
|
||||
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
|
||||
!selectedMethods.includes(methodSearch.toUpperCase()) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
methodSearch.toUpperCase(),
|
||||
onMethodsChange
|
||||
);
|
||||
setMethodSearch('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{methodSearch.toUpperCase()}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{METHOD_OPTIONS.filter(
|
||||
(method) => !selectedMethods.includes(method)
|
||||
).map((method) => (
|
||||
<CommandItem
|
||||
key={method}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
method,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{method}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='HTTP Methods'
|
||||
emptyLabel='All methods'
|
||||
selectedValues={selectedMethods}
|
||||
onSelectedValuesChange={onMethodsChange}
|
||||
options={METHOD_OPTIONS}
|
||||
searchValue={methodSearch}
|
||||
onSearchValueChange={setMethodSearch}
|
||||
searchPlaceholder='Search or add method...'
|
||||
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Methods'
|
||||
normalizeCustomValue={(value) => value.toUpperCase()}
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>Endpoints</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1 overflow-hidden'>
|
||||
{selectedEndpoints.length > 0 ? (
|
||||
selectedEndpoints.map((endpoint) => (
|
||||
<FilterBadge
|
||||
key={endpoint}
|
||||
value={endpoint}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
val,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>
|
||||
All endpoints
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-80 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add endpoint pattern...'
|
||||
value={endpointSearch}
|
||||
onValueChange={setEndpointSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedEndpoints.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedEndpoints.map((endpoint) => (
|
||||
<CommandItem
|
||||
key={`selected-${endpoint}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpoint,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{endpoint}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{endpointSearch &&
|
||||
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
|
||||
!selectedEndpoints.includes(endpointSearch) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpointSearch,
|
||||
onEndpointsChange
|
||||
);
|
||||
setEndpointSearch('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{endpointSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading='Common Endpoints'>
|
||||
{ENDPOINT_OPTIONS.filter(
|
||||
(endpoint) => !selectedEndpoints.includes(endpoint)
|
||||
).map((endpoint) => (
|
||||
<CommandItem
|
||||
key={endpoint}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpoint,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{endpoint}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='Endpoints'
|
||||
emptyLabel='All endpoints'
|
||||
selectedValues={selectedEndpoints}
|
||||
onSelectedValuesChange={onEndpointsChange}
|
||||
options={ENDPOINT_OPTIONS}
|
||||
searchValue={endpointSearch}
|
||||
onSearchValueChange={setEndpointSearch}
|
||||
searchPlaceholder='Search or add endpoint pattern...'
|
||||
popoverClassName='w-[min(20rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Common Endpoints'
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='request-id'>Request ID</Label>
|
||||
@@ -652,7 +346,7 @@ export function LogFilters({
|
||||
type='text'
|
||||
placeholder='Search by request ID'
|
||||
value={requestId}
|
||||
onChange={(e) => onRequestIdChange(e.target.value)}
|
||||
onChange={(event) => onRequestIdChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -668,14 +362,14 @@ export function LogFilters({
|
||||
type='text'
|
||||
placeholder='Search in message and name'
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchTextChange(e.target.value)}
|
||||
onChange={(event) => onSearchTextChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='limit'>Limit</Label>
|
||||
{isCustom ? (
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
id='limit'
|
||||
type='number'
|
||||
@@ -686,7 +380,7 @@ export function LogFilters({
|
||||
onKeyDown={handleCustomLimitKeyDown}
|
||||
onBlur={handleCustomLimitApply}
|
||||
autoFocus
|
||||
className='flex-1'
|
||||
className='flex-1 sm:flex-auto'
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -695,6 +389,7 @@ export function LogFilters({
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
|
||||
if (!isPreset) {
|
||||
onLimitChange(100);
|
||||
}
|
||||
@@ -712,12 +407,11 @@ export function LogFilters({
|
||||
<SelectValue placeholder='Select limit' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='25'>25</SelectItem>
|
||||
<SelectItem value='50'>50</SelectItem>
|
||||
<SelectItem value='100'>100</SelectItem>
|
||||
<SelectItem value='200'>200</SelectItem>
|
||||
<SelectItem value='500'>500</SelectItem>
|
||||
<SelectItem value='1000'>1000</SelectItem>
|
||||
{PRESET_LIMITS.map((preset) => (
|
||||
<SelectItem key={preset} value={preset}>
|
||||
{preset}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value='custom'>Custom...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -727,8 +421,7 @@ export function LogFilters({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label> </Label>
|
||||
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant='outline'
|
||||
|
||||
182
ui/app/logs/multi-select-command-filter.tsx
Normal file
182
ui/app/logs/multi-select-command-filter.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
interface QuickFilterOption {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface MultiSelectCommandFilterProps {
|
||||
label: string;
|
||||
emptyLabel: string;
|
||||
selectedValues: string[];
|
||||
onSelectedValuesChange: (values: string[]) => void;
|
||||
options: string[];
|
||||
searchValue: string;
|
||||
onSearchValueChange: (value: string) => void;
|
||||
searchPlaceholder: string;
|
||||
popoverClassName?: string;
|
||||
selectedGroupLabel?: string;
|
||||
customGroupLabel?: string;
|
||||
quickGroupLabel?: string;
|
||||
optionsGroupLabel?: string;
|
||||
quickFilters?: QuickFilterOption[];
|
||||
normalizeCustomValue?: (value: string) => string;
|
||||
canAddCustom?: (value: string) => boolean;
|
||||
}
|
||||
|
||||
function FilterBadge({ value }: { value: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='flex items-center gap-1 px-1 font-normal'
|
||||
>
|
||||
{value}
|
||||
<X className='h-3 w-3 opacity-70' aria-hidden='true' />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectCommandFilter({
|
||||
label,
|
||||
emptyLabel,
|
||||
selectedValues,
|
||||
onSelectedValuesChange,
|
||||
options,
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
searchPlaceholder,
|
||||
popoverClassName = 'w-64 p-0',
|
||||
selectedGroupLabel = 'Selected',
|
||||
customGroupLabel = 'Custom',
|
||||
quickGroupLabel = 'Quick Filters',
|
||||
optionsGroupLabel = 'Options',
|
||||
quickFilters = [],
|
||||
normalizeCustomValue,
|
||||
canAddCustom,
|
||||
}: MultiSelectCommandFilterProps) {
|
||||
const toggleSelection = (value: string) => {
|
||||
if (selectedValues.includes(value)) {
|
||||
onSelectedValuesChange(selectedValues.filter((item) => item !== value));
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectedValuesChange([...selectedValues, value]);
|
||||
};
|
||||
|
||||
const normalizedSearch = normalizeCustomValue
|
||||
? normalizeCustomValue(searchValue)
|
||||
: searchValue;
|
||||
|
||||
const canShowCustomAction =
|
||||
normalizedSearch.length > 0 &&
|
||||
!options.includes(normalizedSearch) &&
|
||||
!selectedValues.includes(normalizedSearch) &&
|
||||
(canAddCustom ? canAddCustom(normalizedSearch) : true);
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label>{label}</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1 overflow-hidden'>
|
||||
{selectedValues.length > 0 ? (
|
||||
selectedValues.map((value) => (
|
||||
<FilterBadge key={value} value={value} />
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>{emptyLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={popoverClassName} align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onValueChange={onSearchValueChange}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedValues.length > 0 && (
|
||||
<CommandGroup heading={selectedGroupLabel}>
|
||||
{selectedValues.map((value) => (
|
||||
<CommandItem
|
||||
key={`selected-${value}`}
|
||||
onSelect={() => toggleSelection(value)}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{canShowCustomAction && (
|
||||
<CommandGroup heading={customGroupLabel}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(normalizedSearch);
|
||||
onSearchValueChange('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{normalizedSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
{quickFilters.length > 0 && (
|
||||
<CommandGroup heading={quickGroupLabel}>
|
||||
{quickFilters.map((filter) => (
|
||||
<CommandItem key={filter.label} onSelect={filter.onSelect}>
|
||||
<Checkbox checked={filter.checked} className='mr-2' />
|
||||
{filter.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup heading={optionsGroupLabel}>
|
||||
{options
|
||||
.filter((option) => !selectedValues.includes(option))
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
onSelect={() => toggleSelection(option)}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,9 +12,18 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { FileText, RefreshCw } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { LogEntry, LogsResponse } from './types';
|
||||
import { LogFilters } from './log-filters';
|
||||
import { LogEntryCard } from './log-entry-card';
|
||||
@@ -135,126 +142,125 @@ export default function LogsPage() {
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
Boolean(requestId) ||
|
||||
Boolean(searchText) ||
|
||||
selectedStatusCodes.length > 0 ||
|
||||
selectedMethods.length > 0 ||
|
||||
selectedEndpoints.length > 0;
|
||||
|
||||
const activeFilterDescription = [
|
||||
selectedDate !== 'all' ? `date ${selectedDate}` : null,
|
||||
selectedLevel !== 'all' ? `level ${selectedLevel}` : null,
|
||||
requestId ? `request ID ${requestId}` : null,
|
||||
searchText ? `text "${searchText}"` : null,
|
||||
selectedStatusCodes.length > 0
|
||||
? `status ${selectedStatusCodes.join(', ')}`
|
||||
: null,
|
||||
selectedMethods.length > 0 ? `method ${selectedMethods.join(', ')}` : null,
|
||||
selectedEndpoints.length > 0
|
||||
? `endpoint ${selectedEndpoints.join(', ')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='overflow-x-hidden p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
|
||||
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
|
||||
System Logs
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
|
||||
View and filter application logs
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetchLogs()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='self-start'
|
||||
>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='System Logs'
|
||||
description='View and filter application logs.'
|
||||
actions={
|
||||
<Button onClick={() => refetchLogs()} variant='outline' size='sm'>
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<LogFilters
|
||||
selectedDate={selectedDate}
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
selectedStatusCodes={selectedStatusCodes}
|
||||
selectedMethods={selectedMethods}
|
||||
selectedEndpoints={selectedEndpoints}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onStatusCodesChange={setSelectedStatusCodes}
|
||||
onMethodsChange={setSelectedMethods}
|
||||
onEndpointsChange={setSelectedEndpoints}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
<LogFilters
|
||||
selectedDate={selectedDate}
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
selectedStatusCodes={selectedStatusCodes}
|
||||
selectedMethods={selectedMethods}
|
||||
selectedEndpoints={selectedEndpoints}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onStatusCodesChange={setSelectedStatusCodes}
|
||||
onMethodsChange={setSelectedMethods}
|
||||
onEndpointsChange={setSelectedEndpoints}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-lg sm:text-xl'>Log Entries</span>
|
||||
{logsData && (
|
||||
<Badge variant='secondary' className='text-xs sm:text-sm'>
|
||||
{logsData.logs.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{(selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
requestId ||
|
||||
searchText ||
|
||||
selectedStatusCodes.length > 0 ||
|
||||
selectedMethods.length > 0 ||
|
||||
selectedEndpoints.length > 0) && (
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
Showing logs
|
||||
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
||||
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
||||
{requestId && ` with request ID ${requestId}`}
|
||||
{searchText && ` matching "${searchText}"`}
|
||||
{selectedStatusCodes.length > 0 &&
|
||||
` with status ${selectedStatusCodes.join(', ')}`}
|
||||
{selectedMethods.length > 0 &&
|
||||
` with method ${selectedMethods.join(', ')}`}
|
||||
{selectedEndpoints.length > 0 &&
|
||||
` with endpoint ${selectedEndpoints.join(', ')}`}
|
||||
</CardDescription>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>Log Entries</CardTitle>
|
||||
{logsData && (
|
||||
<Badge variant='secondary'>
|
||||
{logsData.logs.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden p-3 sm:p-6'>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<RefreshCw className='h-6 w-6 animate-spin' />
|
||||
<span className='ml-2 text-sm sm:text-base'>
|
||||
Loading logs...
|
||||
</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Showing logs filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{isLoading ? (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`logs-loading-${index}`}
|
||||
className='h-16 w-full rounded-lg'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : logsData?.logs && logsData.logs.length > 0 ? (
|
||||
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
|
||||
<div className='space-y-2 pr-3'>
|
||||
{logsData.logs.map((entry, index) => (
|
||||
<LogEntryCard
|
||||
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
|
||||
entry={entry}
|
||||
onClick={handleLogClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : logsData?.logs && logsData.logs.length > 0 ? (
|
||||
<>
|
||||
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
|
||||
<div className='space-y-2 pr-3'>
|
||||
{logsData.logs.map((entry, index) => (
|
||||
<LogEntryCard
|
||||
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
|
||||
entry={entry}
|
||||
onClick={handleLogClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
|
||||
<p className='text-sm sm:text-base'>No log entries found</p>
|
||||
<p className='text-xs sm:text-sm'>
|
||||
Try adjusting your filters or check back later
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<Empty className='py-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<FileText className='h-4 w-4' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No log entries found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Try adjusting your filters or check back later.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LogDetailsDialog
|
||||
log={selectedLog}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<LogDetailsDialog
|
||||
log={selectedLog}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ModelSelector } from '@/components/ModelSelector';
|
||||
import { ModelTester } from '@/components/ModelTester';
|
||||
import { ApiEndpointTester } from '@/components/ApiEndpointTester';
|
||||
import { ModelSearchFilter } from '@/components/ModelSearchFilter';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertCircle, Users, Globe } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-models-with-providers'],
|
||||
queryFn: () => AdminService.getModelsWithProviders(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const { models = [], groups = [] } = modelsData || {};
|
||||
|
||||
const groupedModels = useMemo(() => {
|
||||
return groupAndSortModelsByProvider(models);
|
||||
}, [models]);
|
||||
|
||||
const groupDataMap = useMemo(() => {
|
||||
return new Map(groups.map((group) => [group.provider, group]));
|
||||
}, [groups]);
|
||||
|
||||
const providerInfo = useMemo(() => {
|
||||
const allProviders = new Set([
|
||||
...Object.keys(groupedModels),
|
||||
...groups.map((g) => g.provider),
|
||||
]);
|
||||
console.log(allProviders);
|
||||
|
||||
return Array.from(allProviders).map((provider) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
const groupData = groupDataMap.get(provider);
|
||||
const activeModels = providerModels.filter(
|
||||
(m) => m.isEnabled && !m.soft_deleted
|
||||
).length;
|
||||
const totalModels = providerModels.length;
|
||||
|
||||
return {
|
||||
provider,
|
||||
activeModels,
|
||||
totalModels,
|
||||
groupData,
|
||||
hasGroupUrl: !!groupData?.group_url,
|
||||
hasGroupApiKey: !!groupData?.group_api_key,
|
||||
};
|
||||
});
|
||||
}, [groupedModels, groupDataMap, groups]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>
|
||||
Model Management & API Testing
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue='manage' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-3'>
|
||||
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
|
||||
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
|
||||
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='manage' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Manage your AI models organized by provider groups. Configure
|
||||
API keys, and organize models by provider groups.
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[60px] w-full' />
|
||||
<Skeleton className='h-[400px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models. Please try refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Tabs defaultValue='all' className='w-full'>
|
||||
<div className='space-y-4'>
|
||||
{/* Provider Tabs Navigation */}
|
||||
<div className='overflow-x-auto rounded-lg border p-1'>
|
||||
<TabsList className='grid w-full max-w-full min-w-max auto-cols-fr grid-flow-col gap-1 sm:gap-2'>
|
||||
<TabsTrigger
|
||||
value='all'
|
||||
className='flex items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<Globe className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
<span className='hidden sm:inline'>All Models</span>
|
||||
<span className='sm:hidden'>All</span>
|
||||
<Badge variant='secondary' className='ml-1 text-xs'>
|
||||
{models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
{providerInfo.map(
|
||||
({ provider, activeModels, totalModels }) => (
|
||||
<TabsTrigger
|
||||
key={provider}
|
||||
value={provider}
|
||||
className='flex min-w-fit items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<Users className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
<span className='max-w-20 truncate sm:max-w-none'>
|
||||
{provider}
|
||||
</span>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='ml-1 text-xs'
|
||||
>
|
||||
{activeModels}/{totalModels}
|
||||
</Badge>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
)
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* All Models Tab */}
|
||||
<TabsContent value='all'>
|
||||
<div className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Overview of all models across all provider groups.
|
||||
</div>
|
||||
<ModelSearchFilter
|
||||
models={models}
|
||||
onFilteredModelsChange={setFilteredModels}
|
||||
/>
|
||||
<ModelSelector
|
||||
filteredModels={filteredModels}
|
||||
showDeleteAllButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{providerInfo.map(
|
||||
({ provider, totalModels, groupData }) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
|
||||
return (
|
||||
<TabsContent key={provider} value={provider}>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h3 className='flex items-center gap-2 text-lg font-semibold'>
|
||||
<Users className='h-5 w-5' />
|
||||
{provider}
|
||||
</h3>
|
||||
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
|
||||
{providerModels.filter(
|
||||
(m) => m.soft_deleted
|
||||
).length > 0 && (
|
||||
<span className='text-orange-600'>
|
||||
{
|
||||
providerModels.filter(
|
||||
(m) => m.soft_deleted
|
||||
).length
|
||||
}{' '}
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
{groupData?.group_url && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Globe className='h-3 w-3' />
|
||||
{groupData.group_url}
|
||||
</span>
|
||||
)}
|
||||
{totalModels === 0 && (
|
||||
<span className='text-muted-foreground'>
|
||||
No models configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{totalModels === 0 && (
|
||||
<Alert className='mb-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium'>
|
||||
No models found for this provider
|
||||
</p>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>
|
||||
Common issues:
|
||||
</p>
|
||||
<ul className='ml-2 list-inside list-disc space-y-1'>
|
||||
<li>
|
||||
<strong>API credentials:</strong>{' '}
|
||||
Check if the API key is correct
|
||||
and has the right permissions
|
||||
</li>
|
||||
<li>
|
||||
<strong>Base URL:</strong> Verify
|
||||
the base URL is correct for your
|
||||
provider
|
||||
</li>
|
||||
<li>
|
||||
<strong>Network access:</strong>{' '}
|
||||
Ensure the server can reach the
|
||||
provider's API endpoint
|
||||
</li>
|
||||
<li>
|
||||
<strong>Provider status:</strong>{' '}
|
||||
The upstream provider might be
|
||||
temporarily unavailable
|
||||
</li>
|
||||
</ul>
|
||||
{groupData?.group_url && (
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
Current endpoint:{' '}
|
||||
<code className='bg-muted rounded px-1'>
|
||||
{groupData.group_url}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<ModelSelector
|
||||
filterProvider={provider}
|
||||
groupData={groupData}
|
||||
showProviderActions={true}
|
||||
showDeleteAllButton={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-basic' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Test model credentials and connectivity with basic chat
|
||||
completion requests through the secure proxy (resolves CORS
|
||||
and Docker network issues). Models can be tested even without
|
||||
API keys configured (useful for free models or when
|
||||
authentication is handled elsewhere).
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[200px] w-full' />
|
||||
<Skeleton className='h-[100px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for testing. Please try refreshing
|
||||
the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ModelTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-api' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Comprehensive testing of all OpenAI API endpoints including
|
||||
chat completions, embeddings, image generation, audio
|
||||
synthesis, and model listing through the secure proxy
|
||||
(resolves CORS and Docker network issues). Models can be
|
||||
tested with or without API keys configured.
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[300px] w-full' />
|
||||
<Skeleton className='h-[200px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for API testing. Please try
|
||||
refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ApiEndpointTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
253
ui/app/models/page.tsx
Normal file
253
ui/app/models/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ModelSelector } from '@/components/model-selector';
|
||||
import { ModelTester } from '@/components/model-tester';
|
||||
import { ApiEndpointTester } from '@/components/api-endpoint-tester';
|
||||
import { ModelSearchFilter } from '@/components/model-search-filter';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [selectedProviderScope, setSelectedProviderScope] =
|
||||
useState<string>('all');
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-models-with-providers'],
|
||||
queryFn: () => AdminService.getModelsWithProviders(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const { models = [], groups = [] } = modelsData || {};
|
||||
|
||||
const groupedModels = useMemo(
|
||||
() => groupAndSortModelsByProvider(models),
|
||||
[models]
|
||||
);
|
||||
|
||||
const groupDataMap = useMemo(
|
||||
() => new Map(groups.map((group) => [group.provider, group])),
|
||||
[groups]
|
||||
);
|
||||
|
||||
const providerInfo = useMemo(() => {
|
||||
const allProviders = new Set([
|
||||
...Object.keys(groupedModels),
|
||||
...groups.map((group) => group.provider),
|
||||
]);
|
||||
|
||||
return Array.from(allProviders)
|
||||
.map((provider) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
const groupData = groupDataMap.get(provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
totalModels: providerModels.length,
|
||||
disabledModels: providerModels.filter((model) => model.soft_deleted)
|
||||
.length,
|
||||
groupData,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||
}, [groupDataMap, groupedModels, groups]);
|
||||
|
||||
const activeProviderScope = useMemo(() => {
|
||||
if (selectedProviderScope === 'all') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const providerExists = providerInfo.some(
|
||||
(provider) => provider.provider === selectedProviderScope
|
||||
);
|
||||
|
||||
return providerExists ? selectedProviderScope : 'all';
|
||||
}, [providerInfo, selectedProviderScope]);
|
||||
|
||||
const selectedProviderGroup =
|
||||
activeProviderScope === 'all'
|
||||
? undefined
|
||||
: groupDataMap.get(activeProviderScope);
|
||||
|
||||
const scopedModels = useMemo(() => {
|
||||
if (activeProviderScope === 'all') {
|
||||
return models;
|
||||
}
|
||||
|
||||
return models.filter((model) => model.provider === activeProviderScope);
|
||||
}, [activeProviderScope, models]);
|
||||
|
||||
return (
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<PageHeader
|
||||
title='Model Management'
|
||||
description='Manage provider model catalogs and validate endpoints from one place.'
|
||||
/>
|
||||
|
||||
<Tabs defaultValue='manage' className='w-full gap-3 sm:gap-4'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='w-full snap-x snap-mandatory justify-start gap-0.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
>
|
||||
<TabsTrigger
|
||||
value='manage'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
Manage Models
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='test-basic'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
Basic Testing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='test-api'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
API Endpoints
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='manage' className='mt-0'>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-16 w-full' />
|
||||
<Skeleton className='h-[420px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models. Please try refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='flex flex-col gap-2 sm:gap-2.5 md:flex-row md:items-center'>
|
||||
<Select
|
||||
value={activeProviderScope}
|
||||
onValueChange={(value) => {
|
||||
setSelectedProviderScope(value);
|
||||
setFilteredModels(undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full md:w-[220px]'>
|
||||
<SelectValue placeholder='Provider scope' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='start'>
|
||||
<SelectItem value='all'>
|
||||
All providers ({models.length})
|
||||
</SelectItem>
|
||||
{providerInfo.map(({ provider, totalModels }) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider} ({totalModels})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ModelSearchFilter
|
||||
models={scopedModels}
|
||||
onFilteredModelsChange={setFilteredModels}
|
||||
className='w-full min-w-0 flex-1'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
filterProvider={
|
||||
activeProviderScope === 'all'
|
||||
? undefined
|
||||
: activeProviderScope
|
||||
}
|
||||
groupData={selectedProviderGroup}
|
||||
filteredModels={filteredModels}
|
||||
showDeleteAllButton={activeProviderScope === 'all'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-basic' className='mt-0 space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-base font-semibold'>
|
||||
Basic Credential Testing
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Run chat-completion checks through the secure proxy to validate
|
||||
model credentials and endpoint connectivity.
|
||||
</p>
|
||||
</div>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[220px] w-full' />
|
||||
<Skeleton className='h-[120px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for testing. Please try refreshing the
|
||||
page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ModelTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-api' className='mt-0 space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-base font-semibold'>
|
||||
OpenAI Endpoint Testing
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Validate chat, embeddings, image, audio, and model-listing
|
||||
endpoints through the secure proxy.
|
||||
</p>
|
||||
</div>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[320px] w-full' />
|
||||
<Skeleton className='h-[220px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for API testing. Please try refreshing
|
||||
the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ApiEndpointTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
1296
ui/app/page.tsx
1296
ui/app/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,11 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
import { ProtectedRoute } from '@/lib/auth/ProtectedRoute';
|
||||
import { AuthProvider } from '@/lib/auth/auth-context';
|
||||
import { ProtectedRoute } from '@/lib/auth/protected-route';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
interface ProvidersProps {
|
||||
children: ReactNode;
|
||||
@@ -34,12 +35,14 @@ export function Providers({ children }: ProvidersProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
<Toaster position='top-right' />
|
||||
</ProtectedRoute>
|
||||
</AuthProvider>
|
||||
<TooltipProvider>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
<Toaster position='top-right' />
|
||||
</ProtectedRoute>
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,37 +4,30 @@ import * as React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
|
||||
import { AdminSettings } from '@/components/settings/admin-settings';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { Toaster } from 'sonner';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='flex items-center'>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>Settings</h1>
|
||||
</div>
|
||||
<Tabs defaultValue='admin' className='w-full'>
|
||||
<TabsList className='mb-4'>
|
||||
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='server'>
|
||||
<ServerConfigSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value='admin'>
|
||||
<AdminSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<Toaster />
|
||||
</SidebarProvider>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='Settings'
|
||||
description='Manage admin authentication, service metadata, and upstream forwarding.'
|
||||
/>
|
||||
<Tabs defaultValue='admin' className='w-full'>
|
||||
<TabsList variant='line' className='mb-4 w-full'>
|
||||
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
|
||||
<TabsTrigger value='server'>Server Config</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='server'>
|
||||
<ServerConfigSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value='admin'>
|
||||
<AdminSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
created_at: string;
|
||||
token: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
interface PaginatedTransactionsResponse {
|
||||
transactions: Transaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
const TransactionService = {
|
||||
getAllTransactions: async (): Promise<Transaction[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<Transaction[]>('/api/transactions');
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transactions:', error);
|
||||
throw new Error('Failed to fetch transactions');
|
||||
}
|
||||
},
|
||||
|
||||
getPaginatedTransactions: async (
|
||||
page: number,
|
||||
perPage: number
|
||||
): Promise<PaginatedTransactionsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<PaginatedTransactionsResponse>(
|
||||
`/api/transactions/paginated/${page}/${perPage}`
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch paginated transactions:', error);
|
||||
throw new Error('Failed to fetch paginated transactions');
|
||||
}
|
||||
},
|
||||
|
||||
getRecentTransactions: async (limit: number): Promise<Transaction[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<Transaction[]>(
|
||||
`/api/transactions/recent/${limit}`
|
||||
);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent transactions:', error);
|
||||
throw new Error('Failed to fetch recent transactions');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const perPage = 20;
|
||||
|
||||
// Fetch paginated transactions data
|
||||
const {
|
||||
data: paginationData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['transactions', currentPage, perPage],
|
||||
queryFn: () =>
|
||||
TransactionService.getPaginatedTransactions(currentPage, perPage),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
const transactions = paginationData?.transactions || [];
|
||||
const totalPages = paginationData?.total_pages || 0;
|
||||
const total = paginationData?.total || 0;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatAmount = (amount: string) => {
|
||||
return `${parseInt(amount).toLocaleString()} msats`;
|
||||
};
|
||||
|
||||
const truncateToken = (token: string) => {
|
||||
if (token.length <= 20) return token;
|
||||
return `${token.slice(0, 10)}...${token.slice(-10)}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Token copied to clipboard!');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
toast.error('Failed to copy token');
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>
|
||||
Transaction History
|
||||
</h1>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
View all Cashu token transactions processed by the system
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='space-y-4'>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className='h-[120px] w-full' />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load transactions.{' '}
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: 'Please check if the server is running and try refreshing the page.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className='py-8 text-center'>
|
||||
<p className='text-muted-foreground'>
|
||||
No transactions found.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Showing {(currentPage - 1) * perPage + 1} to{' '}
|
||||
{Math.min(currentPage * perPage, total)} of {total}{' '}
|
||||
transactions
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-[100px]'>ID</TableHead>
|
||||
<TableHead>Date & Time</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead className='w-[400px]'>
|
||||
Cashu Token
|
||||
</TableHead>
|
||||
<TableHead className='w-[60px]'>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction) => (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{transaction.id.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
{formatDate(transaction.created_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='secondary'>
|
||||
{formatAmount(transaction.amount)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className='max-w-[300px] cursor-pointer truncate rounded px-1 py-0.5 font-mono text-xs hover:bg-gray-100'>
|
||||
{truncateToken(transaction.token)}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='max-w-md break-all'>
|
||||
<p className='font-mono text-xs'>
|
||||
{transaction.token}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
copyToClipboard(transaction.token)
|
||||
}
|
||||
className='h-8 w-8 p-0'
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={goToPrevious}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className='mr-1 h-4 w-4' />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
<div className='flex items-center space-x-1'>
|
||||
{Array.from(
|
||||
{ length: Math.min(5, totalPages) },
|
||||
(_, i) => {
|
||||
const pageNumber =
|
||||
currentPage <= 3
|
||||
? i + 1
|
||||
: currentPage >= totalPages - 2
|
||||
? totalPages - 4 + i
|
||||
: currentPage - 2 + i;
|
||||
|
||||
if (pageNumber < 1 || pageNumber > totalPages)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant={
|
||||
currentPage === pageNumber
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
size='sm'
|
||||
onClick={() => goToPage(pageNumber)}
|
||||
className='h-9 w-9 p-0'
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={goToNext}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className='ml-1 h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,30 +3,34 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ShieldAlertIcon } from 'lucide-react';
|
||||
import { AuthPageShell } from '@/components/auth-page-shell';
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className='bg-background flex min-h-screen flex-col items-center justify-center p-4'>
|
||||
<div className='flex max-w-md flex-col items-center space-y-6 text-center'>
|
||||
<ShieldAlertIcon className='text-destructive h-24 w-24' />
|
||||
|
||||
<h1 className='text-4xl font-bold'>Access Denied</h1>
|
||||
|
||||
<p className='text-muted-foreground text-lg'>
|
||||
You don't have permission to access this page. Please contact
|
||||
your administrator if you believe this is an error.
|
||||
<AuthPageShell
|
||||
title='Access Denied'
|
||||
description="You don't have permission to access this page."
|
||||
>
|
||||
<div className='flex flex-col items-center gap-6'>
|
||||
<ShieldAlertIcon className='text-destructive h-20 w-20' />
|
||||
<p className='text-muted-foreground text-center text-sm'>
|
||||
Contact your administrator if you believe this is an error.
|
||||
</p>
|
||||
|
||||
<div className='flex gap-4'>
|
||||
<Button onClick={() => router.push('/')}>Go to Dashboard</Button>
|
||||
|
||||
<Button variant='outline' onClick={() => router.back()}>
|
||||
<div className='flex w-full flex-col gap-3 sm:w-auto sm:flex-row'>
|
||||
<Button onClick={() => router.push('/')} className='w-full sm:w-auto'>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => router.back()}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "stone",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -17,5 +19,5 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -45,9 +46,10 @@ export function AddModelForm({
|
||||
isOpen,
|
||||
}: AddModelFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
type ManualModelInput = z.input<typeof ManualModelSchema>;
|
||||
|
||||
const form = useForm<ManualModel>({
|
||||
resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<ManualModelInput, unknown, ManualModel>({
|
||||
resolver: zodResolver(ManualModelSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
full_name: '',
|
||||
699
ui/components/api-endpoint-form.tsx
Normal file
699
ui/components/api-endpoint-form.tsx
Normal file
@@ -0,0 +1,699 @@
|
||||
import type { ChangeEvent, Dispatch, SetStateAction } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Image as ImageIcon, List, Mic, MicOff, Volume2 } from 'lucide-react';
|
||||
import type { EndpointType } from '@/components/api-endpoint-types';
|
||||
|
||||
interface ApiEndpointFormProps {
|
||||
selectedEndpoint: EndpointType;
|
||||
maxTokens: number;
|
||||
setMaxTokens: Dispatch<SetStateAction<number>>;
|
||||
temperature: number;
|
||||
setTemperature: Dispatch<SetStateAction<number>>;
|
||||
systemMessage: string;
|
||||
setSystemMessage: Dispatch<SetStateAction<string>>;
|
||||
userMessage: string;
|
||||
setUserMessage: Dispatch<SetStateAction<string>>;
|
||||
|
||||
visionMaxTokens: number;
|
||||
setVisionMaxTokens: Dispatch<SetStateAction<number>>;
|
||||
visionTemperature: number;
|
||||
setVisionTemperature: Dispatch<SetStateAction<number>>;
|
||||
visionSystemMessage: string;
|
||||
setVisionSystemMessage: Dispatch<SetStateAction<string>>;
|
||||
visionUserMessage: string;
|
||||
setVisionUserMessage: Dispatch<SetStateAction<string>>;
|
||||
imageDetail: 'low' | 'high' | 'auto';
|
||||
setImageDetail: Dispatch<SetStateAction<'low' | 'high' | 'auto'>>;
|
||||
selectedImage: File | null;
|
||||
imagePreviewUrl: string | null;
|
||||
onImageUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onRemoveImage: () => void;
|
||||
|
||||
embeddingInput: string;
|
||||
setEmbeddingInput: Dispatch<SetStateAction<string>>;
|
||||
encodingFormat: 'float' | 'base64';
|
||||
setEncodingFormat: Dispatch<SetStateAction<'float' | 'base64'>>;
|
||||
|
||||
imageCount: number;
|
||||
setImageCount: Dispatch<SetStateAction<number>>;
|
||||
imageSize: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
|
||||
setImageSize: Dispatch<
|
||||
SetStateAction<
|
||||
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
|
||||
>
|
||||
>;
|
||||
imageQuality: 'standard' | 'hd';
|
||||
setImageQuality: Dispatch<SetStateAction<'standard' | 'hd'>>;
|
||||
imageStyle: 'vivid' | 'natural';
|
||||
setImageStyle: Dispatch<SetStateAction<'vivid' | 'natural'>>;
|
||||
imagePrompt: string;
|
||||
setImagePrompt: Dispatch<SetStateAction<string>>;
|
||||
|
||||
speechVoice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
|
||||
setSpeechVoice: Dispatch<
|
||||
SetStateAction<'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'>
|
||||
>;
|
||||
speechFormat: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
|
||||
setSpeechFormat: Dispatch<
|
||||
SetStateAction<'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'>
|
||||
>;
|
||||
speechSpeed: number;
|
||||
setSpeechSpeed: Dispatch<SetStateAction<number>>;
|
||||
speechInput: string;
|
||||
setSpeechInput: Dispatch<SetStateAction<string>>;
|
||||
|
||||
audioTranscriptionPrompt: string;
|
||||
setAudioTranscriptionPrompt: Dispatch<SetStateAction<string>>;
|
||||
audioTemperature: number;
|
||||
setAudioTemperature: Dispatch<SetStateAction<number>>;
|
||||
audioLanguage: string;
|
||||
setAudioLanguage: Dispatch<SetStateAction<string>>;
|
||||
audioResponseFormat: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
|
||||
setAudioResponseFormat: Dispatch<
|
||||
SetStateAction<'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'>
|
||||
>;
|
||||
isRecording: boolean;
|
||||
onStartRecording: () => void;
|
||||
onStopRecording: () => void;
|
||||
onAudioUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
recordedAudio: File | null;
|
||||
recordingUrl: string | null;
|
||||
onRemoveAudio: () => void;
|
||||
}
|
||||
|
||||
export function ApiEndpointForm({
|
||||
selectedEndpoint,
|
||||
maxTokens,
|
||||
setMaxTokens,
|
||||
temperature,
|
||||
setTemperature,
|
||||
systemMessage,
|
||||
setSystemMessage,
|
||||
userMessage,
|
||||
setUserMessage,
|
||||
visionMaxTokens,
|
||||
setVisionMaxTokens,
|
||||
visionTemperature,
|
||||
setVisionTemperature,
|
||||
visionSystemMessage,
|
||||
setVisionSystemMessage,
|
||||
visionUserMessage,
|
||||
setVisionUserMessage,
|
||||
imageDetail,
|
||||
setImageDetail,
|
||||
selectedImage,
|
||||
imagePreviewUrl,
|
||||
onImageUpload,
|
||||
onRemoveImage,
|
||||
embeddingInput,
|
||||
setEmbeddingInput,
|
||||
encodingFormat,
|
||||
setEncodingFormat,
|
||||
imageCount,
|
||||
setImageCount,
|
||||
imageSize,
|
||||
setImageSize,
|
||||
imageQuality,
|
||||
setImageQuality,
|
||||
imageStyle,
|
||||
setImageStyle,
|
||||
imagePrompt,
|
||||
setImagePrompt,
|
||||
speechVoice,
|
||||
setSpeechVoice,
|
||||
speechFormat,
|
||||
setSpeechFormat,
|
||||
speechSpeed,
|
||||
setSpeechSpeed,
|
||||
speechInput,
|
||||
setSpeechInput,
|
||||
audioTranscriptionPrompt,
|
||||
setAudioTranscriptionPrompt,
|
||||
audioTemperature,
|
||||
setAudioTemperature,
|
||||
audioLanguage,
|
||||
setAudioLanguage,
|
||||
audioResponseFormat,
|
||||
setAudioResponseFormat,
|
||||
isRecording,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onAudioUpload,
|
||||
recordedAudio,
|
||||
recordingUrl,
|
||||
onRemoveAudio,
|
||||
}: ApiEndpointFormProps) {
|
||||
switch (selectedEndpoint) {
|
||||
case 'chat-completions':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='max-tokens'>Max Tokens</Label>
|
||||
<Input
|
||||
id='max-tokens'
|
||||
type='number'
|
||||
min={1}
|
||||
max={4000}
|
||||
value={maxTokens}
|
||||
onChange={(event) =>
|
||||
setMaxTokens(parseInt(event.target.value) || 150)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='temperature'>Temperature</Label>
|
||||
<Input
|
||||
id='temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(event) =>
|
||||
setTemperature(parseFloat(event.target.value) || 0.7)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='system-message'>System Message (Optional)</Label>
|
||||
<Textarea
|
||||
id='system-message'
|
||||
placeholder='Enter system message...'
|
||||
value={systemMessage}
|
||||
onChange={(event) => setSystemMessage(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='user-message'>Test Message</Label>
|
||||
<Textarea
|
||||
id='user-message'
|
||||
placeholder='Enter your test message...'
|
||||
value={userMessage}
|
||||
onChange={(event) => setUserMessage(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'vision-chat':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-max-tokens'>Max Tokens</Label>
|
||||
<Input
|
||||
id='vision-max-tokens'
|
||||
type='number'
|
||||
min={1}
|
||||
max={4000}
|
||||
value={visionMaxTokens}
|
||||
onChange={(event) =>
|
||||
setVisionMaxTokens(parseInt(event.target.value) || 300)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-temperature'>Temperature</Label>
|
||||
<Input
|
||||
id='vision-temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={visionTemperature}
|
||||
onChange={(event) =>
|
||||
setVisionTemperature(parseFloat(event.target.value) || 0.7)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-system-message'>
|
||||
System Message (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id='vision-system-message'
|
||||
placeholder='Enter system message...'
|
||||
value={visionSystemMessage}
|
||||
onChange={(event) => setVisionSystemMessage(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-user-message'>Test Message</Label>
|
||||
<Textarea
|
||||
id='vision-user-message'
|
||||
placeholder='Enter your test message...'
|
||||
value={visionUserMessage}
|
||||
onChange={(event) => setVisionUserMessage(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-detail'>Image Detail</Label>
|
||||
<Select
|
||||
value={imageDetail}
|
||||
onValueChange={(value: 'low' | 'high' | 'auto') =>
|
||||
setImageDetail(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='low'>Low</SelectItem>
|
||||
<SelectItem value='high'>High</SelectItem>
|
||||
<SelectItem value='auto'>Auto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-upload'>Upload Image (Optional)</Label>
|
||||
<Input
|
||||
type='file'
|
||||
accept='image/*'
|
||||
onChange={onImageUpload}
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
{selectedImage && (
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
<ImageIcon className='text-muted-foreground h-5 w-5' />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Selected image: {selectedImage.name}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onRemoveImage}
|
||||
className='ml-0 sm:ml-auto'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{imagePreviewUrl && (
|
||||
<div className='relative mt-2 aspect-square w-32 overflow-hidden rounded-md border'>
|
||||
<Image
|
||||
src={imagePreviewUrl}
|
||||
alt='Image preview'
|
||||
fill
|
||||
className='object-cover'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'embeddings':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='encoding-format'>Encoding Format</Label>
|
||||
<Select
|
||||
value={encodingFormat}
|
||||
onValueChange={(value: 'float' | 'base64') =>
|
||||
setEncodingFormat(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='float'>Float</SelectItem>
|
||||
<SelectItem value='base64'>Base64</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='embedding-input'>Text Input</Label>
|
||||
<Textarea
|
||||
id='embedding-input'
|
||||
placeholder='Enter text to generate embeddings for...'
|
||||
value={embeddingInput}
|
||||
onChange={(event) => setEmbeddingInput(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'images':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-count'>Number of Images</Label>
|
||||
<Input
|
||||
id='image-count'
|
||||
type='number'
|
||||
min={1}
|
||||
max={10}
|
||||
value={imageCount}
|
||||
onChange={(event) =>
|
||||
setImageCount(parseInt(event.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-size'>Size</Label>
|
||||
<Select
|
||||
value={imageSize}
|
||||
onValueChange={(
|
||||
value:
|
||||
| '256x256'
|
||||
| '512x512'
|
||||
| '1024x1024'
|
||||
| '1792x1024'
|
||||
| '1024x1792'
|
||||
) => setImageSize(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='256x256'>256x256</SelectItem>
|
||||
<SelectItem value='512x512'>512x512</SelectItem>
|
||||
<SelectItem value='1024x1024'>1024x1024</SelectItem>
|
||||
<SelectItem value='1792x1024'>1792x1024</SelectItem>
|
||||
<SelectItem value='1024x1792'>1024x1792</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-quality'>Quality</Label>
|
||||
<Select
|
||||
value={imageQuality}
|
||||
onValueChange={(value: 'standard' | 'hd') =>
|
||||
setImageQuality(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='standard'>Standard</SelectItem>
|
||||
<SelectItem value='hd'>HD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-style'>Style</Label>
|
||||
<Select
|
||||
value={imageStyle}
|
||||
onValueChange={(value: 'vivid' | 'natural') =>
|
||||
setImageStyle(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='vivid'>Vivid</SelectItem>
|
||||
<SelectItem value='natural'>Natural</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-prompt'>Image Prompt</Label>
|
||||
<Textarea
|
||||
id='image-prompt'
|
||||
placeholder='Describe the image you want to generate...'
|
||||
value={imagePrompt}
|
||||
onChange={(event) => setImagePrompt(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio-speech':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-voice'>Voice</Label>
|
||||
<Select
|
||||
value={speechVoice}
|
||||
onValueChange={(
|
||||
value:
|
||||
| 'alloy'
|
||||
| 'echo'
|
||||
| 'fable'
|
||||
| 'onyx'
|
||||
| 'nova'
|
||||
| 'shimmer'
|
||||
) => setSpeechVoice(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='alloy'>Alloy</SelectItem>
|
||||
<SelectItem value='echo'>Echo</SelectItem>
|
||||
<SelectItem value='fable'>Fable</SelectItem>
|
||||
<SelectItem value='onyx'>Onyx</SelectItem>
|
||||
<SelectItem value='nova'>Nova</SelectItem>
|
||||
<SelectItem value='shimmer'>Shimmer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-format'>Response Format</Label>
|
||||
<Select
|
||||
value={speechFormat}
|
||||
onValueChange={(
|
||||
value: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
|
||||
) => setSpeechFormat(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='mp3'>MP3</SelectItem>
|
||||
<SelectItem value='opus'>Opus</SelectItem>
|
||||
<SelectItem value='aac'>AAC</SelectItem>
|
||||
<SelectItem value='flac'>FLAC</SelectItem>
|
||||
<SelectItem value='wav'>WAV</SelectItem>
|
||||
<SelectItem value='pcm'>PCM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-speed'>Speed</Label>
|
||||
<Input
|
||||
id='speech-speed'
|
||||
type='number'
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.25}
|
||||
value={speechSpeed}
|
||||
onChange={(event) =>
|
||||
setSpeechSpeed(parseFloat(event.target.value) || 1.0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-input'>Text to Synthesize</Label>
|
||||
<Textarea
|
||||
id='speech-input'
|
||||
placeholder='Enter text to convert to speech...'
|
||||
value={speechInput}
|
||||
onChange={(event) => setSpeechInput(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio-transcription':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-prompt'>
|
||||
Prompt (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id='audio-transcription-prompt'
|
||||
placeholder='Enter a prompt for the transcription...'
|
||||
value={audioTranscriptionPrompt}
|
||||
onChange={(event) =>
|
||||
setAudioTranscriptionPrompt(event.target.value)
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-temperature'>
|
||||
Temperature
|
||||
</Label>
|
||||
<Input
|
||||
id='audio-transcription-temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={audioTemperature}
|
||||
onChange={(event) =>
|
||||
setAudioTemperature(parseFloat(event.target.value) || 0.0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-language'>
|
||||
Language (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id='audio-transcription-language'
|
||||
placeholder='e.g., en-US, fr-FR'
|
||||
value={audioLanguage}
|
||||
onChange={(event) => setAudioLanguage(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-response-format'>
|
||||
Response Format
|
||||
</Label>
|
||||
<Select
|
||||
value={audioResponseFormat}
|
||||
onValueChange={(
|
||||
value: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
|
||||
) => setAudioResponseFormat(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='json'>JSON</SelectItem>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
<SelectItem value='srt'>SRT</SelectItem>
|
||||
<SelectItem value='verbose_json'>Verbose JSON</SelectItem>
|
||||
<SelectItem value='vtt'>VTT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Voice Recording</Label>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant={isRecording ? 'destructive' : 'default'}
|
||||
size='sm'
|
||||
onClick={isRecording ? onStopRecording : onStartRecording}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<MicOff className='h-4 w-4' />
|
||||
Stop Recording
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className='h-4 w-4' />
|
||||
Start Recording
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isRecording && (
|
||||
<Badge variant='destructive' className='gap-1.5'>
|
||||
<span className='h-2 w-2 animate-pulse rounded-full bg-current' />
|
||||
Recording...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-upload'>
|
||||
Or Upload Audio File
|
||||
</Label>
|
||||
<Input
|
||||
type='file'
|
||||
accept='audio/*'
|
||||
onChange={onAudioUpload}
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
{recordedAudio && (
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
<Volume2 className='text-muted-foreground h-5 w-5' />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Selected audio: {recordedAudio.name}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onRemoveAudio}
|
||||
className='ml-0 sm:ml-auto'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{recordingUrl && (
|
||||
<div className='bg-muted mt-2 rounded-md p-4'>
|
||||
<audio controls src={recordingUrl} className='w-full'>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'models':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Alert>
|
||||
<List className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
This endpoint lists all available models from the provider. No
|
||||
additional parameters are required.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
306
ui/components/api-endpoint-response.tsx
Normal file
306
ui/components/api-endpoint-response.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import Image from 'next/image';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: {
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
finish_reason: string;
|
||||
}[];
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmbeddingResponse {
|
||||
object: string;
|
||||
data: {
|
||||
object: string;
|
||||
index: number;
|
||||
embedding: number[];
|
||||
}[];
|
||||
model: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
created: number;
|
||||
data: {
|
||||
url: string;
|
||||
revised_prompt?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AudioResponse {
|
||||
type: 'audio';
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionResponse {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ModelsListResponse {
|
||||
object: string;
|
||||
data: {
|
||||
id: string;
|
||||
object?: string;
|
||||
created?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type ApiResponse =
|
||||
| ChatCompletionResponse
|
||||
| EmbeddingResponse
|
||||
| ImageGenerationResponse
|
||||
| AudioResponse
|
||||
| AudioTranscriptionResponse
|
||||
| ModelsListResponse;
|
||||
|
||||
const isAudioResponse = (response: ApiResponse): response is AudioResponse => {
|
||||
return 'type' in response && response.type === 'audio';
|
||||
};
|
||||
|
||||
const isChatCompletionResponse = (
|
||||
response: ApiResponse
|
||||
): response is ChatCompletionResponse => {
|
||||
return 'choices' in response;
|
||||
};
|
||||
|
||||
const isEmbeddingResponse = (
|
||||
response: ApiResponse
|
||||
): response is EmbeddingResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'embedding' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isImageGenerationResponse = (
|
||||
response: ApiResponse
|
||||
): response is ImageGenerationResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'url' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isModelsListResponse = (
|
||||
response: ApiResponse
|
||||
): response is ModelsListResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'id' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isAudioTranscriptionResponse = (
|
||||
response: ApiResponse
|
||||
): response is AudioTranscriptionResponse => {
|
||||
return 'text' in response;
|
||||
};
|
||||
|
||||
export function ApiEndpointResponse({
|
||||
response,
|
||||
}: {
|
||||
response: ApiResponse | null;
|
||||
}) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAudioResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Generated Audio</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<audio controls src={response.url} className='w-full'>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
Audio file size: {(response.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAudioTranscriptionResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Transcription Result</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-sm whitespace-pre-wrap'>
|
||||
{response.text || 'No transcription result available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isChatCompletionResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Model Response</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-sm whitespace-pre-wrap'>
|
||||
{response.choices?.[0]?.message?.content ||
|
||||
'No content in response'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{response.usage && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Usage Statistics</Label>
|
||||
<div className='grid grid-cols-1 gap-2 text-sm sm:grid-cols-3 sm:gap-4'>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.prompt_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Prompt Tokens</div>
|
||||
</div>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.completion_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Completion Tokens</div>
|
||||
</div>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.total_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Total Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmbeddingResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Embedding Vector</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-muted-foreground mb-2 text-sm'>
|
||||
Generated {response.data?.[0]?.embedding?.length || 0} dimensional
|
||||
embedding vector
|
||||
</p>
|
||||
<details className='group'>
|
||||
<summary className='hover:text-foreground cursor-pointer text-sm'>
|
||||
Show first 10 values
|
||||
</summary>
|
||||
<pre className='mt-2 text-xs'>
|
||||
{JSON.stringify(
|
||||
response.data?.[0]?.embedding?.slice(0, 10),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
...
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{response.usage && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Usage Statistics</Label>
|
||||
<div className='bg-muted rounded p-2 text-center text-sm'>
|
||||
<div className='font-semibold'>{response.usage.total_tokens}</div>
|
||||
<div className='text-muted-foreground'>Total Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImageGenerationResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Generated Images</Label>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
{response.data?.map((image, index: number) => (
|
||||
<div key={index} className='space-y-2'>
|
||||
<div className='relative aspect-square w-full'>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={`Generated image ${index + 1}`}
|
||||
fill
|
||||
className='rounded-md border object-cover'
|
||||
unoptimized={true}
|
||||
/>
|
||||
</div>
|
||||
{image.revised_prompt && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Revised prompt: {image.revised_prompt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isModelsListResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Available Models</Label>
|
||||
<div className='max-h-60 overflow-auto'>
|
||||
<div className='grid gap-2'>
|
||||
{response.data?.map((model) => (
|
||||
<div key={model.id} className='bg-muted rounded-md p-3'>
|
||||
<div className='font-medium'>{model.id}</div>
|
||||
{model.object && (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Type: {model.object}
|
||||
</div>
|
||||
)}
|
||||
{model.created && (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Created:{' '}
|
||||
{new Date(model.created * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
726
ui/components/api-endpoint-tester.tsx
Normal file
726
ui/components/api-endpoint-tester.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { type Model } from '@/lib/api/schemas/models';
|
||||
import { ModelService } from '@/lib/api/services/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Loader2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info,
|
||||
Key,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ApiEndpointResponse,
|
||||
type ApiResponse,
|
||||
} from '@/components/api-endpoint-response';
|
||||
import { ApiEndpointForm } from '@/components/api-endpoint-form';
|
||||
import {
|
||||
API_ENDPOINTS,
|
||||
DEFAULT_REQUESTS,
|
||||
type EndpointType,
|
||||
type ChatCompletionRequest,
|
||||
type EmbeddingRequest,
|
||||
type ImageGenerationRequest,
|
||||
type AudioSpeechRequest,
|
||||
type AudioTranscriptionRequest,
|
||||
type EndpointRequestData,
|
||||
} from '@/components/api-endpoint-types';
|
||||
|
||||
interface ApiEndpointTesterProps {
|
||||
models: Model[];
|
||||
}
|
||||
|
||||
export function ApiEndpointTester({ models }: ApiEndpointTesterProps) {
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
||||
const [selectedEndpoint, setSelectedEndpoint] =
|
||||
useState<EndpointType>('chat-completions');
|
||||
const [response, setResponse] = useState<ApiResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Chat Completions state
|
||||
const [systemMessage, setSystemMessage] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].systemMessage
|
||||
);
|
||||
const [userMessage, setUserMessage] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].userMessage
|
||||
);
|
||||
const [maxTokens, setMaxTokens] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].maxTokens
|
||||
);
|
||||
const [temperature, setTemperature] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].temperature
|
||||
);
|
||||
|
||||
// Vision Chat state
|
||||
const [visionSystemMessage, setVisionSystemMessage] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].systemMessage
|
||||
);
|
||||
const [visionUserMessage, setVisionUserMessage] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].userMessage
|
||||
);
|
||||
const [visionMaxTokens, setVisionMaxTokens] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].maxTokens
|
||||
);
|
||||
const [visionTemperature, setVisionTemperature] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].temperature
|
||||
);
|
||||
const [imageDetail, setImageDetail] = useState<'low' | 'high' | 'auto'>(
|
||||
DEFAULT_REQUESTS['vision-chat'].imageDetail
|
||||
);
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// Voice Recording state
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordedAudio, setRecordedAudio] = useState<File | null>(null);
|
||||
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
|
||||
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(
|
||||
null
|
||||
);
|
||||
const [audioTranscriptionPrompt, setAudioTranscriptionPrompt] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].prompt
|
||||
);
|
||||
const [audioResponseFormat, setAudioResponseFormat] = useState<
|
||||
'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
|
||||
>(DEFAULT_REQUESTS['audio-transcription'].response_format);
|
||||
const [audioTemperature, setAudioTemperature] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].temperature
|
||||
);
|
||||
const [audioLanguage, setAudioLanguage] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].language
|
||||
);
|
||||
|
||||
// Embeddings state
|
||||
const [embeddingInput, setEmbeddingInput] = useState(
|
||||
DEFAULT_REQUESTS.embeddings.input
|
||||
);
|
||||
const [encodingFormat, setEncodingFormat] = useState<'float' | 'base64'>(
|
||||
DEFAULT_REQUESTS.embeddings.encoding_format
|
||||
);
|
||||
|
||||
// Image Generation state
|
||||
const [imagePrompt, setImagePrompt] = useState(
|
||||
DEFAULT_REQUESTS.images.prompt
|
||||
);
|
||||
const [imageCount, setImageCount] = useState(DEFAULT_REQUESTS.images.n);
|
||||
const [imageSize, setImageSize] = useState<
|
||||
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
|
||||
>(DEFAULT_REQUESTS.images.size);
|
||||
const [imageQuality, setImageQuality] = useState<'standard' | 'hd'>(
|
||||
DEFAULT_REQUESTS.images.quality
|
||||
);
|
||||
const [imageStyle, setImageStyle] = useState<'vivid' | 'natural'>(
|
||||
DEFAULT_REQUESTS.images.style
|
||||
);
|
||||
|
||||
// Audio Speech state
|
||||
const [speechInput, setSpeechInput] = useState(
|
||||
DEFAULT_REQUESTS['audio-speech'].input
|
||||
);
|
||||
const [speechVoice, setSpeechVoice] = useState<
|
||||
'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'
|
||||
>(DEFAULT_REQUESTS['audio-speech'].voice);
|
||||
const [speechFormat, setSpeechFormat] = useState<
|
||||
'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
|
||||
>(DEFAULT_REQUESTS['audio-speech'].response_format);
|
||||
const [speechSpeed, setSpeechSpeed] = useState(
|
||||
DEFAULT_REQUESTS['audio-speech'].speed
|
||||
);
|
||||
|
||||
// Fetch model groups for API key resolution
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['model-groups'],
|
||||
queryFn: () => ModelService.getModelGroups(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const selectedModel = models.find((model) => model.id === selectedModelId);
|
||||
|
||||
// Image upload handler
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
setSelectedImage(file);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setImagePreviewUrl(previewUrl);
|
||||
} else {
|
||||
toast.error('Please select a valid image file');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Audio file upload handler
|
||||
const handleAudioUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type.startsWith('audio/')) {
|
||||
setRecordedAudio(file);
|
||||
const audioUrl = URL.createObjectURL(file);
|
||||
setRecordingUrl(audioUrl);
|
||||
} else {
|
||||
toast.error('Please select a valid audio file');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Voice recording functions
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: BlobPart[] = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' });
|
||||
const file = new File([blob], 'recording.webm', { type: 'audio/webm' });
|
||||
setRecordedAudio(file);
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
setRecordingUrl(audioUrl);
|
||||
|
||||
// Stop all tracks to release microphone
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setMediaRecorder(recorder);
|
||||
setIsRecording(true);
|
||||
toast.success('Recording started');
|
||||
} catch {
|
||||
toast.error(
|
||||
'Failed to start recording. Please check microphone permissions.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder) {
|
||||
mediaRecorder.stop();
|
||||
setMediaRecorder(null);
|
||||
setIsRecording(false);
|
||||
toast.success('Recording stopped');
|
||||
}
|
||||
};
|
||||
|
||||
// Convert file to base64 for vision API
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Get effective API key and endpoint URL for the selected model
|
||||
const getModelCredentials = (model: Model) => {
|
||||
const group = groups.find((g) => g.provider === model.provider);
|
||||
|
||||
// Determine API key (individual takes precedence over group)
|
||||
const apiKey = model.api_key || group?.group_api_key;
|
||||
|
||||
// Determine base endpoint URL
|
||||
let baseUrl = model.url;
|
||||
|
||||
// If model URL is relative and group has a base URL, combine them
|
||||
if (model.url.startsWith('/') && group?.group_url) {
|
||||
baseUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`;
|
||||
}
|
||||
|
||||
// Remove any existing endpoint path to get base URL
|
||||
baseUrl = baseUrl.replace(/\/v1\/.*$/, '').replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
group,
|
||||
};
|
||||
};
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string, endpointPath: string) => {
|
||||
return `${baseUrl}${endpointPath}`;
|
||||
};
|
||||
|
||||
const buildRequest = async (): Promise<EndpointRequestData> => {
|
||||
if (!selectedModel) return null;
|
||||
|
||||
switch (selectedEndpoint) {
|
||||
case 'chat-completions':
|
||||
const messages = [];
|
||||
if (systemMessage.trim()) {
|
||||
messages.push({
|
||||
role: 'system' as const,
|
||||
content: systemMessage.trim(),
|
||||
});
|
||||
}
|
||||
messages.push({ role: 'user' as const, content: userMessage.trim() });
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
messages,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
} as ChatCompletionRequest;
|
||||
|
||||
case 'vision-chat':
|
||||
if (!selectedImage) {
|
||||
throw new Error('Please select an image for vision analysis');
|
||||
}
|
||||
|
||||
const imageBase64 = await fileToBase64(selectedImage);
|
||||
const visionMessages = [];
|
||||
|
||||
if (visionSystemMessage.trim()) {
|
||||
visionMessages.push({
|
||||
role: 'system' as const,
|
||||
content: visionSystemMessage.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
visionMessages.push({
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: visionUserMessage.trim(),
|
||||
},
|
||||
{
|
||||
type: 'image_url' as const,
|
||||
image_url: {
|
||||
url: imageBase64,
|
||||
detail: imageDetail,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
messages: visionMessages,
|
||||
max_tokens: visionMaxTokens,
|
||||
temperature: visionTemperature,
|
||||
} as ChatCompletionRequest;
|
||||
|
||||
case 'embeddings':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
input: embeddingInput,
|
||||
encoding_format: encodingFormat,
|
||||
} as EmbeddingRequest;
|
||||
|
||||
case 'images':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
prompt: imagePrompt,
|
||||
n: imageCount,
|
||||
size: imageSize,
|
||||
quality: imageQuality,
|
||||
style: imageStyle,
|
||||
} as ImageGenerationRequest;
|
||||
|
||||
case 'audio-speech':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
input: speechInput,
|
||||
voice: speechVoice,
|
||||
response_format: speechFormat,
|
||||
speed: speechSpeed,
|
||||
} as AudioSpeechRequest;
|
||||
|
||||
case 'audio-transcription':
|
||||
if (!recordedAudio) {
|
||||
throw new Error(
|
||||
'Please record or upload an audio file for transcription'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
file: recordedAudio,
|
||||
prompt: audioTranscriptionPrompt.trim() || undefined,
|
||||
response_format: audioResponseFormat,
|
||||
temperature: audioTemperature,
|
||||
language: audioLanguage.trim() || undefined,
|
||||
} as AudioTranscriptionRequest;
|
||||
|
||||
case 'models':
|
||||
return null; // No request body needed for models endpoint
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const testEndpointMutation = useMutation({
|
||||
mutationFn: async (requestData: EndpointRequestData) => {
|
||||
if (!selectedModel) {
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setResponse(null);
|
||||
|
||||
try {
|
||||
const response = await ModelService.testModel(
|
||||
selectedModel.id,
|
||||
selectedEndpoint,
|
||||
requestData
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Test failed');
|
||||
}
|
||||
|
||||
return response.data as ApiResponse;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to test endpoint via proxy';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResponse(data);
|
||||
toast.success(
|
||||
`${API_ENDPOINTS[selectedEndpoint].name} test completed successfully!`
|
||||
);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
const errorMessage = err?.message || 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
toast.error(
|
||||
`${API_ENDPOINTS[selectedEndpoint].name} test failed: ${errorMessage}`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!selectedModel) {
|
||||
toast.error('Please select a model to test');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields based on endpoint
|
||||
if (selectedEndpoint === 'chat-completions' && !userMessage.trim()) {
|
||||
toast.error('Please enter a test message');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'vision-chat') {
|
||||
if (!visionUserMessage.trim()) {
|
||||
toast.error('Please enter a test message');
|
||||
return;
|
||||
}
|
||||
if (!selectedImage) {
|
||||
toast.error('Please select an image for vision analysis');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'embeddings' && !embeddingInput.trim()) {
|
||||
toast.error('Please enter text for embedding');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'images' && !imagePrompt.trim()) {
|
||||
toast.error('Please enter an image prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'audio-speech' && !speechInput.trim()) {
|
||||
toast.error('Please enter text for speech synthesis');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'audio-transcription' && !recordedAudio) {
|
||||
toast.error('Please record or upload an audio file for transcription');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = await buildRequest();
|
||||
testEndpointMutation.mutate(requestData);
|
||||
};
|
||||
|
||||
const enabledModels = models.filter((model) => model.isEnabled);
|
||||
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
||||
const endpointUrl = credentials
|
||||
? buildEndpointUrl(
|
||||
credentials.baseUrl,
|
||||
API_ENDPOINTS[selectedEndpoint].path
|
||||
)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Card className='w-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>API Endpoint Tester</CardTitle>
|
||||
<CardDescription>
|
||||
Comprehensive testing of OpenAI-compatible API endpoints through the
|
||||
secure proxy (resolves CORS and network issues)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
{/* Model and Endpoint Selection */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-select'>Select Model</Label>
|
||||
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
|
||||
<SelectTrigger id='model-select'>
|
||||
<SelectValue placeholder='Choose a model to test...' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{model.name}</span>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
{model.provider}
|
||||
</Badge>
|
||||
{model.is_free && (
|
||||
<Badge variant='secondary' className='text-xs'>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='endpoint-select'>API Endpoint</Label>
|
||||
<Select
|
||||
value={selectedEndpoint}
|
||||
onValueChange={(value: EndpointType) =>
|
||||
setSelectedEndpoint(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id='endpoint-select'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(API_ENDPOINTS).map(([key, endpoint]) => {
|
||||
const Icon = endpoint.icon;
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon className='h-4 w-4' />
|
||||
<span>{endpoint.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Information */}
|
||||
{selectedModel && credentials && (
|
||||
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Globe className='h-4 w-4' />
|
||||
<span className='break-all'>
|
||||
<strong>Endpoint:</strong> {endpointUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
<span>
|
||||
<strong>API Key:</strong>{' '}
|
||||
{credentials.apiKey
|
||||
? `${credentials.apiKey.substring(0, 8)}...`
|
||||
: 'Not configured'}
|
||||
</span>
|
||||
<Badge
|
||||
variant={credentials.apiKey ? 'default' : 'destructive'}
|
||||
className='text-xs'
|
||||
>
|
||||
{selectedModel.api_key_type || 'Unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<strong>Provider:</strong> {selectedModel.provider}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<strong>Description:</strong>{' '}
|
||||
{API_ENDPOINTS[selectedEndpoint].description}
|
||||
</span>
|
||||
</div>
|
||||
{!credentials.apiKey && (
|
||||
<Alert variant='default' className='mt-2'>
|
||||
<Info className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
No API key configured for this model. Testing may still work
|
||||
if the model is free or if authentication is handled
|
||||
elsewhere. For models requiring authentication, please add an
|
||||
API key to the model or its provider group.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ApiEndpointForm
|
||||
selectedEndpoint={selectedEndpoint}
|
||||
maxTokens={maxTokens}
|
||||
setMaxTokens={setMaxTokens}
|
||||
temperature={temperature}
|
||||
setTemperature={setTemperature}
|
||||
systemMessage={systemMessage}
|
||||
setSystemMessage={setSystemMessage}
|
||||
userMessage={userMessage}
|
||||
setUserMessage={setUserMessage}
|
||||
visionMaxTokens={visionMaxTokens}
|
||||
setVisionMaxTokens={setVisionMaxTokens}
|
||||
visionTemperature={visionTemperature}
|
||||
setVisionTemperature={setVisionTemperature}
|
||||
visionSystemMessage={visionSystemMessage}
|
||||
setVisionSystemMessage={setVisionSystemMessage}
|
||||
visionUserMessage={visionUserMessage}
|
||||
setVisionUserMessage={setVisionUserMessage}
|
||||
imageDetail={imageDetail}
|
||||
setImageDetail={setImageDetail}
|
||||
selectedImage={selectedImage}
|
||||
imagePreviewUrl={imagePreviewUrl}
|
||||
onImageUpload={handleImageUpload}
|
||||
onRemoveImage={() => {
|
||||
setSelectedImage(null);
|
||||
setImagePreviewUrl(null);
|
||||
}}
|
||||
embeddingInput={embeddingInput}
|
||||
setEmbeddingInput={setEmbeddingInput}
|
||||
encodingFormat={encodingFormat}
|
||||
setEncodingFormat={setEncodingFormat}
|
||||
imageCount={imageCount}
|
||||
setImageCount={setImageCount}
|
||||
imageSize={imageSize}
|
||||
setImageSize={setImageSize}
|
||||
imageQuality={imageQuality}
|
||||
setImageQuality={setImageQuality}
|
||||
imageStyle={imageStyle}
|
||||
setImageStyle={setImageStyle}
|
||||
imagePrompt={imagePrompt}
|
||||
setImagePrompt={setImagePrompt}
|
||||
speechVoice={speechVoice}
|
||||
setSpeechVoice={setSpeechVoice}
|
||||
speechFormat={speechFormat}
|
||||
setSpeechFormat={setSpeechFormat}
|
||||
speechSpeed={speechSpeed}
|
||||
setSpeechSpeed={setSpeechSpeed}
|
||||
speechInput={speechInput}
|
||||
setSpeechInput={setSpeechInput}
|
||||
audioTranscriptionPrompt={audioTranscriptionPrompt}
|
||||
setAudioTranscriptionPrompt={setAudioTranscriptionPrompt}
|
||||
audioTemperature={audioTemperature}
|
||||
setAudioTemperature={setAudioTemperature}
|
||||
audioLanguage={audioLanguage}
|
||||
setAudioLanguage={setAudioLanguage}
|
||||
audioResponseFormat={audioResponseFormat}
|
||||
setAudioResponseFormat={setAudioResponseFormat}
|
||||
isRecording={isRecording}
|
||||
onStartRecording={startRecording}
|
||||
onStopRecording={stopRecording}
|
||||
onAudioUpload={handleAudioUpload}
|
||||
recordedAudio={recordedAudio}
|
||||
recordingUrl={recordingUrl}
|
||||
onRemoveAudio={() => {
|
||||
setRecordedAudio(null);
|
||||
setRecordingUrl(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Test Button */}
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={!selectedModelId || testEndpointMutation.isPending}
|
||||
className='w-full'
|
||||
>
|
||||
{testEndpointMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Testing {API_ENDPOINTS[selectedEndpoint].name}...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className='mr-2 h-4 w-4' />
|
||||
Test {API_ENDPOINTS[selectedEndpoint].name}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Results */}
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<XCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<strong>Test Failed:</strong> {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{response && (
|
||||
<Alert>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<strong>Test Successful!</strong>{' '}
|
||||
{API_ENDPOINTS[selectedEndpoint].name} endpoint responded
|
||||
correctly.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ApiEndpointResponse response={response} />
|
||||
|
||||
{response && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Raw Response</Label>
|
||||
<details className='group'>
|
||||
<summary className='text-muted-foreground hover:text-foreground cursor-pointer text-sm'>
|
||||
<Info className='mr-1 inline h-4 w-4' />
|
||||
Show detailed response data
|
||||
</summary>
|
||||
<pre className='bg-muted mt-2 max-h-60 overflow-auto rounded-md p-4 text-xs'>
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
156
ui/components/api-endpoint-types.ts
Normal file
156
ui/components/api-endpoint-types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
FileText,
|
||||
Eye,
|
||||
List,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
'chat-completions': {
|
||||
name: 'Chat Completions',
|
||||
path: '/chat/completions',
|
||||
icon: FileText,
|
||||
description: 'Test conversational AI with chat completion requests',
|
||||
},
|
||||
'vision-chat': {
|
||||
name: 'Vision Chat (Image + Text)',
|
||||
path: '/chat/completions',
|
||||
icon: Eye,
|
||||
description: 'Analyze images with text prompts using vision models',
|
||||
},
|
||||
embeddings: {
|
||||
name: 'Embeddings',
|
||||
path: '/embeddings',
|
||||
icon: List,
|
||||
description: 'Generate embeddings for text input',
|
||||
},
|
||||
images: {
|
||||
name: 'Image Generation',
|
||||
path: '/images/generations',
|
||||
icon: ImageIcon,
|
||||
description: 'Generate images from text prompts',
|
||||
},
|
||||
'audio-speech': {
|
||||
name: 'Text-to-Speech',
|
||||
path: '/audio/speech',
|
||||
icon: Mic,
|
||||
description: 'Convert text to speech audio',
|
||||
},
|
||||
'audio-transcription': {
|
||||
name: 'Audio Transcription',
|
||||
path: '/audio/transcriptions',
|
||||
icon: Volume2,
|
||||
description: 'Transcribe audio files to text',
|
||||
},
|
||||
models: {
|
||||
name: 'List Models',
|
||||
path: '/models',
|
||||
icon: List,
|
||||
description: 'List all available models from the provider',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type EndpointType = keyof typeof API_ENDPOINTS;
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string;
|
||||
messages: {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content:
|
||||
| string
|
||||
| Array<{
|
||||
type: 'text' | 'image_url';
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
detail?: 'low' | 'high' | 'auto';
|
||||
};
|
||||
}>;
|
||||
}[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface EmbeddingRequest {
|
||||
model: string;
|
||||
input: string | string[];
|
||||
encoding_format?: 'float' | 'base64';
|
||||
}
|
||||
|
||||
export interface ImageGenerationRequest {
|
||||
model?: string;
|
||||
prompt: string;
|
||||
n?: number;
|
||||
size?: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
|
||||
quality?: 'standard' | 'hd';
|
||||
style?: 'vivid' | 'natural';
|
||||
}
|
||||
|
||||
export interface AudioSpeechRequest {
|
||||
model: string;
|
||||
input: string;
|
||||
voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
|
||||
response_format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionRequest {
|
||||
model: string;
|
||||
file: File;
|
||||
prompt?: string;
|
||||
response_format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
|
||||
temperature?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_REQUESTS = {
|
||||
'chat-completions': {
|
||||
systemMessage: 'You are a helpful assistant. Please respond concisely.',
|
||||
userMessage:
|
||||
'Hello! Can you tell me what model you are and confirm that you are working correctly?',
|
||||
maxTokens: 150,
|
||||
temperature: 0.7,
|
||||
},
|
||||
'vision-chat': {
|
||||
systemMessage:
|
||||
'You are a helpful assistant that can analyze images. Please describe what you see.',
|
||||
userMessage:
|
||||
'What do you see in this image? Please provide a detailed description.',
|
||||
maxTokens: 300,
|
||||
temperature: 0.7,
|
||||
imageDetail: 'auto' as const,
|
||||
},
|
||||
embeddings: {
|
||||
input: 'The quick brown fox jumps over the lazy dog.',
|
||||
encoding_format: 'float' as const,
|
||||
},
|
||||
images: {
|
||||
prompt: 'A beautiful sunset over a mountain landscape',
|
||||
n: 1,
|
||||
size: '1024x1024' as const,
|
||||
quality: 'standard' as const,
|
||||
style: 'vivid' as const,
|
||||
},
|
||||
'audio-speech': {
|
||||
input: 'Hello, this is a test of the text-to-speech functionality.',
|
||||
voice: 'alloy' as const,
|
||||
response_format: 'mp3' as const,
|
||||
speed: 1.0,
|
||||
},
|
||||
'audio-transcription': {
|
||||
prompt: 'This is a test transcription.',
|
||||
response_format: 'json' as const,
|
||||
temperature: 0.0,
|
||||
language: '',
|
||||
},
|
||||
};
|
||||
|
||||
export type EndpointRequestData =
|
||||
| ChatCompletionRequest
|
||||
| EmbeddingRequest
|
||||
| ImageGenerationRequest
|
||||
| AudioSpeechRequest
|
||||
| AudioTranscriptionRequest
|
||||
| null;
|
||||
351
ui/components/app-page-shell.tsx
Normal file
351
ui/components/app-page-shell.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
DatabaseIcon,
|
||||
FileTextIcon,
|
||||
LayoutDashboardIcon,
|
||||
LogOutIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
WalletIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
import { adminLogout } from '@/lib/api/services/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyToggle } from '@/components/currency-toggle';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AppPageShellProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
|
||||
{ title: 'Balances', url: '/balances', icon: WalletIcon },
|
||||
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
|
||||
{ title: 'Models', url: '/models', icon: DatabaseIcon },
|
||||
{ title: 'Providers', url: '/providers', icon: ServerIcon },
|
||||
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
|
||||
] as const;
|
||||
|
||||
function isActivePath(pathname: string, itemUrl: string): boolean {
|
||||
if (itemUrl === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
|
||||
return pathname === itemUrl || pathname.startsWith(`${itemUrl}/`);
|
||||
}
|
||||
|
||||
export function AppPageShell({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: AppPageShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
try {
|
||||
await adminLogout();
|
||||
toast.success('Logged out successfully');
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
toast.error('Failed to logout');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-background text-foreground min-h-dvh overflow-x-clip md:h-screen md:overflow-hidden'>
|
||||
<div className='flex min-h-dvh w-full min-w-0 overflow-x-clip md:h-full'>
|
||||
<aside
|
||||
className={cn(
|
||||
'border-border/60 hidden shrink-0 border-r py-5 transition-[width,padding] duration-300 ease-in-out md:flex md:h-full md:flex-col md:overflow-y-auto',
|
||||
isSidebarCollapsed ? 'w-16 px-2' : 'w-60 px-4'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'px-1 transition-[padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed && 'px-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='shrink-0 rounded-sm'
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden transition-[max-width,opacity,transform] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'max-w-0 -translate-x-1 opacity-0'
|
||||
: 'max-w-[11rem] translate-x-0 opacity-100'
|
||||
)}
|
||||
>
|
||||
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
|
||||
Routstr Node
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground h-8 w-8 shrink-0 transition-transform duration-300 ease-in-out',
|
||||
isSidebarCollapsed ? 'mx-auto' : '-mr-1 ml-auto'
|
||||
)}
|
||||
onClick={() => setIsSidebarCollapsed((current) => !current)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelLeftOpenIcon className='h-4 w-4' />
|
||||
) : (
|
||||
<PanelLeftCloseIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='sr-only'>
|
||||
{isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'mt-5',
|
||||
isSidebarCollapsed
|
||||
? 'flex flex-col items-center space-y-2'
|
||||
: 'space-y-1.5'
|
||||
)}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActivePath(pathname, item.url);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.url}
|
||||
asChild
|
||||
variant={active ? 'outline' : 'ghost'}
|
||||
className={cn(
|
||||
'h-10 rounded-lg transition-[width,padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'mx-auto w-10 justify-center px-0'
|
||||
: 'w-full justify-start'
|
||||
)}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon className='h-4 w-4 shrink-0' />
|
||||
<span
|
||||
className={cn(
|
||||
'overflow-hidden whitespace-nowrap transition-[max-width,opacity,margin] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'ml-0 max-w-0 opacity-0'
|
||||
: 'ml-0.5 max-w-[9rem] opacity-100'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'mt-auto pt-3 transition-[padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'flex flex-col items-center space-y-1.5'
|
||||
: 'space-y-2'
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<>
|
||||
<CurrencyToggle
|
||||
compact
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='mx-auto'
|
||||
/>
|
||||
<ThemeToggle
|
||||
compact
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='mx-auto'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleLogout}
|
||||
className='text-muted-foreground hover:text-foreground mx-auto h-8 w-10 justify-center px-0'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Logout</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className='border-border/60 bg-card/30 space-y-1 rounded-lg border p-1'>
|
||||
<CurrencyToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<ThemeToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleLogout}
|
||||
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-8 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className='relative flex w-full min-w-0 flex-1 flex-col overflow-x-clip md:h-full md:min-h-0'>
|
||||
<div className='bg-background/80 supports-[backdrop-filter]:bg-background/72 sticky top-0 z-30 flex items-center gap-2 px-3 py-2 backdrop-blur-xl md:hidden'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 gap-2 rounded-lg px-3'
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
<PanelLeftOpenIcon className='h-4 w-4' />
|
||||
Menu
|
||||
</Button>
|
||||
</div>
|
||||
<main
|
||||
className={cn(
|
||||
'w-full min-w-0 flex-1 overflow-x-clip p-3 pb-4 sm:p-4 md:min-h-0 md:overflow-y-auto md:p-6 md:pb-6',
|
||||
contentClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
||||
<SheetContent
|
||||
side='left'
|
||||
showCloseButton={false}
|
||||
className='w-[min(88vw,18rem)] p-0 md:hidden'
|
||||
>
|
||||
<SheetTitle className='sr-only'>Navigation sidebar</SheetTitle>
|
||||
<SheetDescription className='sr-only'>
|
||||
Browse admin pages and access sidebar controls.
|
||||
</SheetDescription>
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border/60 px-4 pt-4 pb-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded-sm'
|
||||
/>
|
||||
<p className='truncate text-base font-medium tracking-tight'>
|
||||
Routstr Node
|
||||
</p>
|
||||
</div>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-muted-foreground hover:text-foreground h-8 w-8 shrink-0'
|
||||
>
|
||||
<PanelLeftCloseIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Close sidebar</span>
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-full min-h-0 flex-col px-3 pb-3'>
|
||||
<nav className='space-y-1.5 py-3'>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActivePath(pathname, item.url);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={`mobile-sidebar-${item.url}`}
|
||||
asChild
|
||||
variant={active ? 'outline' : 'ghost'}
|
||||
className='h-10 w-full justify-start rounded-lg'
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
>
|
||||
<Icon className='h-4 w-4' />
|
||||
{item.title}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className='border-border/60 bg-card/30 mt-auto space-y-1 rounded-lg border p-1'>
|
||||
<CurrencyToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<ThemeToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={async () => {
|
||||
setIsMobileSidebarOpen(false);
|
||||
await handleLogout();
|
||||
}}
|
||||
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-9 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
DatabaseIcon,
|
||||
LayoutDashboardIcon,
|
||||
@@ -10,15 +11,18 @@ import {
|
||||
WalletIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { NavSecondary } from '@/components/nav-secondary';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
const data = {
|
||||
@@ -48,7 +52,7 @@ const data = {
|
||||
},
|
||||
{
|
||||
title: 'Models',
|
||||
url: '/model',
|
||||
url: '/models',
|
||||
icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
@@ -68,29 +72,61 @@ const data = {
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar collapsible='offcanvas' {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarHeader className='px-3 pt-4 pb-3'>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className='data-[slot=sidebar-menu-button]:!p-1.5'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded'
|
||||
/>
|
||||
<span className='text-base font-semibold'>Routstr Node</span>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded'
|
||||
/>
|
||||
<div className='space-y-0.5'>
|
||||
<p className='text-sm font-semibold tracking-tight'>
|
||||
Routstr Node
|
||||
</p>
|
||||
<p className='text-muted-foreground text-[11px]'>
|
||||
Admin dashboard
|
||||
</p>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className='flex-1 overflow-y-auto'>
|
||||
<NavSecondary items={data.navSecondary} className='mt-auto' />
|
||||
<SidebarContent className='flex-1 overflow-y-auto px-2 pb-2'>
|
||||
<NavSecondary items={data.navSecondary} />
|
||||
<SidebarGroup className='mt-auto px-0 pt-2 pb-0'>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
|
||||
<Link
|
||||
href='https://docs.routstr.com'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<span>Docs</span>
|
||||
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
|
||||
<Link
|
||||
href='https://chat.routstr.com'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<span>Chat App</span>
|
||||
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
{/*
|
||||
<SidebarFooter>
|
||||
|
||||
38
ui/components/auth-page-shell.tsx
Normal file
38
ui/components/auth-page-shell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface AuthPageShellProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthPageShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: AuthPageShellProps) {
|
||||
return (
|
||||
<div className='bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10 sm:py-12'>
|
||||
<Card className='w-full max-w-md'>
|
||||
<CardHeader className='space-y-1 pb-4'>
|
||||
<CardTitle className='text-center text-2xl font-bold'>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-center'>
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -125,11 +125,11 @@ const chartConfig = {
|
||||
},
|
||||
desktop: {
|
||||
label: 'Desktop',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
mobile: {
|
||||
label: 'Mobile',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
|
||||
@@ -12,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'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>
|
||||
|
||||
@@ -218,11 +218,11 @@ export function CollectModelsDialog({
|
||||
|
||||
{!isLoadingModels && selectedProvider && remoteModels.length > 0 && (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='text-sm font-medium'>
|
||||
{remoteModels.length} models available
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { type Model } from '@/lib/api/schemas/models';
|
||||
import { CostCalculator } from '@/components/CostCalculator';
|
||||
import { CostCalculator } from '@/components/cost-calculator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -5,8 +5,9 @@ import { type Model } from '@/lib/api/schemas/models';
|
||||
import {
|
||||
calculateRequestCost,
|
||||
estimateMinimumTokensForCost,
|
||||
formatCost,
|
||||
} from '@/lib/services/costValidation';
|
||||
} from '@/lib/services/cost-validation';
|
||||
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
|
||||
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -21,6 +22,7 @@ interface CostCalculatorProps {
|
||||
}
|
||||
|
||||
export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
const { displayUnit, usdPerSat } = useDisplayCurrency();
|
||||
const [inputTokens, setInputTokens] = useState<number>(100);
|
||||
const [outputTokens, setOutputTokens] = useState<number>(100);
|
||||
|
||||
@@ -39,6 +41,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
}, [model]);
|
||||
|
||||
const hasMinimumCost = model.min_cost_per_request > 0;
|
||||
const formatDisplayCost = (amountUsd: number) =>
|
||||
formatUsdAmountForDisplayUnit(amountUsd, displayUnit, usdPerSat);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -75,33 +79,33 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
<div className='flex justify-between'>
|
||||
<span>Input Cost ({inputTokens.toLocaleString()} tokens):</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.inputCost)}
|
||||
{formatDisplayCost(costCalculation.inputCost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Output Cost ({outputTokens.toLocaleString()} tokens):</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.outputCost)}
|
||||
{formatDisplayCost(costCalculation.outputCost)}
|
||||
</span>
|
||||
</div>
|
||||
<hr className='my-2' />
|
||||
<div className='flex justify-between'>
|
||||
<span>Base Cost:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.baseCost)}
|
||||
{formatDisplayCost(costCalculation.baseCost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Minimum Cost per Request:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(costCalculation.minCostPerRequest)}
|
||||
{formatDisplayCost(costCalculation.minCostPerRequest)}
|
||||
</span>
|
||||
</div>
|
||||
<hr className='my-2' />
|
||||
<div className='flex justify-between font-medium'>
|
||||
<span>Final Cost:</span>
|
||||
<span className='font-mono text-lg'>
|
||||
{formatCost(costCalculation.finalCost)}
|
||||
{formatDisplayCost(costCalculation.finalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,16 +114,12 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
{/* Minimum Cost Alert */}
|
||||
{hasMinimumCost && (
|
||||
<Alert
|
||||
className={
|
||||
costCalculation.isMinimumApplied
|
||||
? 'border-amber-200 bg-amber-50'
|
||||
: 'border-green-200 bg-green-50'
|
||||
}
|
||||
variant={costCalculation.isMinimumApplied ? 'destructive' : 'default'}
|
||||
>
|
||||
{costCalculation.isMinimumApplied ? (
|
||||
<AlertTriangle className='h-4 w-4 text-amber-600' />
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
) : (
|
||||
<CheckCircle className='h-4 w-4 text-green-600' />
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
)}
|
||||
<AlertTitle>
|
||||
{costCalculation.isMinimumApplied
|
||||
@@ -128,8 +128,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{costCalculation.isMinimumApplied
|
||||
? `The calculated cost (${formatCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatCost(costCalculation.minCostPerRequest)} is applied.`
|
||||
: `The calculated cost (${formatCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatCost(costCalculation.minCostPerRequest)}.`}
|
||||
? `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatDisplayCost(costCalculation.minCostPerRequest)} is applied.`
|
||||
: `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatDisplayCost(costCalculation.minCostPerRequest)}.`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -207,16 +207,20 @@ export function CostCalculator({ model }: CostCalculatorProps) {
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Input cost per 1M tokens:</span>
|
||||
<span className='font-mono'>{formatCost(model.input_cost)}</span>
|
||||
<span className='font-mono'>
|
||||
{formatDisplayCost(model.input_cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Output cost per 1M tokens:</span>
|
||||
<span className='font-mono'>{formatCost(model.output_cost)}</span>
|
||||
<span className='font-mono'>
|
||||
{formatDisplayCost(model.output_cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Minimum cost per request:</span>
|
||||
<span className='font-mono'>
|
||||
{formatCost(model.min_cost_per_request)}
|
||||
{formatDisplayCost(model.min_cost_per_request)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useCurrencyStore } from '@/lib/stores/currency';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronsUpDownIcon, CoinsIcon } from 'lucide-react';
|
||||
import { useCurrencyStore } from '@/lib/stores/currency';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Coins } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CurrencyToggle() {
|
||||
interface CurrencyToggleProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
menuSide?: 'top' | 'right' | 'bottom' | 'left';
|
||||
menuAlign?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
const UNIT_OPTIONS: Array<{ value: DisplayUnit; label: string }> = [
|
||||
{ value: 'msat', label: 'mSAT' },
|
||||
{ value: 'sat', label: 'sat' },
|
||||
{ value: 'usd', label: 'USD' },
|
||||
];
|
||||
|
||||
function getLabel(unit: DisplayUnit): string {
|
||||
const option = UNIT_OPTIONS.find((item) => item.value === unit);
|
||||
return option?.label ?? unit;
|
||||
}
|
||||
|
||||
export function CurrencyToggle({
|
||||
className,
|
||||
compact = false,
|
||||
menuSide = 'bottom',
|
||||
menuAlign = 'end',
|
||||
}: CurrencyToggleProps) {
|
||||
const { displayUnit, setDisplayUnit } = useCurrencyStore();
|
||||
|
||||
const { data: btcUsdPrice } = useQuery({
|
||||
@@ -32,47 +57,55 @@ export function CurrencyToggle() {
|
||||
}
|
||||
}, [displayUnit, usdPerSat, setDisplayUnit]);
|
||||
|
||||
const getLabel = (unit: DisplayUnit) => {
|
||||
switch (unit) {
|
||||
case 'msat':
|
||||
return 'mSAT';
|
||||
case 'sat':
|
||||
return 'sat';
|
||||
case 'usd':
|
||||
return 'USD';
|
||||
default:
|
||||
return unit;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
|
||||
className={cn(
|
||||
'border-border/60 bg-background/65 text-muted-foreground hover:text-foreground rounded-md',
|
||||
compact
|
||||
? 'h-8 w-10 justify-center px-0'
|
||||
: 'h-8 justify-between gap-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Coins className='h-4 w-4' />
|
||||
<span className='hidden sm:inline-block'>
|
||||
{getLabel(displayUnit)}
|
||||
<span className='inline-flex min-w-0 items-center gap-1.5'>
|
||||
<CoinsIcon className='h-3.5 w-3.5 shrink-0' />
|
||||
{compact ? null : (
|
||||
<span className='truncate text-[11px] font-medium uppercase'>
|
||||
{getLabel(displayUnit)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className='uppercase sm:hidden'>{displayUnit}</span>
|
||||
{compact ? (
|
||||
<span className='sr-only'>Currency: {getLabel(displayUnit)}</span>
|
||||
) : (
|
||||
<ChevronsUpDownIcon className='h-3.5 w-3.5 opacity-70' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
|
||||
Millisatoshis (mSAT)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
|
||||
Satoshis (sat)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayUnit('usd')}
|
||||
disabled={!usdPerSat}
|
||||
<DropdownMenuContent side={menuSide} align={menuAlign}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={displayUnit}
|
||||
onValueChange={(value) => {
|
||||
if (value !== 'msat' && value !== 'sat' && value !== 'usd') return;
|
||||
if (value === 'usd' && !usdPerSat) return;
|
||||
setDisplayUnit(value);
|
||||
}}
|
||||
>
|
||||
US Dollar (USD)
|
||||
</DropdownMenuItem>
|
||||
{UNIT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === 'usd' && !usdPerSat}
|
||||
className='uppercase'
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,8 @@ export function DashboardBalanceSummary({
|
||||
queryFn: async () => {
|
||||
return WalletService.getDetailedBalances();
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchInterval: 900000,
|
||||
staleTime: 300000,
|
||||
});
|
||||
|
||||
const calculateTotals = (balances: BalanceDetail[]) => {
|
||||
@@ -59,37 +60,38 @@ export function DashboardBalanceSummary({
|
||||
title: 'Your Balance',
|
||||
value: formatAmount(totals.totalOwner),
|
||||
icon: Coins,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/20',
|
||||
color: 'text-green-600 dark:text-green-300',
|
||||
},
|
||||
{
|
||||
title: 'Total Wallet',
|
||||
value: formatAmount(totals.totalWallet),
|
||||
icon: Wallet,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
||||
color: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
title: 'User Balance',
|
||||
value: formatAmount(totals.totalUser),
|
||||
icon: User,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
|
||||
color: 'text-purple-600 dark:text-purple-300',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<div className='grid grid-cols-2 gap-2.5 max-[359px]:grid-cols-1 sm:gap-3 lg:grid-cols-3'>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
|
||||
<div className={`rounded-full p-2 ${card.bgColor}`}>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
<Card key={card.title} size='sm'>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-1'>
|
||||
<CardTitle className='text-muted-foreground text-[11px] font-medium sm:text-sm'>
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<span className='inline-flex size-6 items-center justify-center sm:size-7'>
|
||||
<card.icon className={`size-3.5 sm:size-4 ${card.color}`} />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{card.value}</div>
|
||||
<CardContent className='pt-0'>
|
||||
<div className='text-base font-semibold break-words tabular-nums sm:text-xl'>
|
||||
{card.value}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -190,11 +190,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
variant='outline'
|
||||
className='text-muted-foreground flex gap-1 px-1.5 [&_svg]:size-3'
|
||||
>
|
||||
{row.original.status === 'Done' ? (
|
||||
<CheckCircle2Icon className='text-green-500 dark:text-green-400' />
|
||||
) : (
|
||||
<LoaderIcon />
|
||||
)}
|
||||
{row.original.status === 'Done' ? <CheckCircle2Icon /> : <LoaderIcon />}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, RefreshCw, AlertCircle, Wallet } from 'lucide-react';
|
||||
import { RefreshCw, AlertCircle, Wallet, User, Coins } from 'lucide-react';
|
||||
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
|
||||
import {
|
||||
Card,
|
||||
@@ -12,6 +12,23 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { WithdrawModal } from '@/components/withdraw-modal';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
@@ -66,18 +83,46 @@ export function DetailedWalletBalance({
|
||||
? calculateTotals(data)
|
||||
: { totalWallet: 0, totalUser: 0, totalOwner: 0 };
|
||||
|
||||
const rows = (data ?? [])
|
||||
.filter(
|
||||
(detail) =>
|
||||
(detail.wallet_balance && detail.wallet_balance > 0) || detail.error
|
||||
)
|
||||
.map((detail, index) => {
|
||||
const walletMsat = convertToMsat(detail.wallet_balance || 0, detail.unit);
|
||||
const userMsat = convertToMsat(detail.user_balance || 0, detail.unit);
|
||||
const ownerMsat = convertToMsat(detail.owner_balance || 0, detail.unit);
|
||||
|
||||
return {
|
||||
key: `${detail.mint_url}-${detail.unit}-${index}`,
|
||||
detail,
|
||||
walletMsat,
|
||||
userMsat,
|
||||
ownerMsat,
|
||||
};
|
||||
});
|
||||
|
||||
const formatMintLabel = (detail: BalanceDetail) =>
|
||||
`${detail.mint_url.replace('https://', '').replace('http://', '')} • ${detail.unit.toUpperCase()}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='h-full w-full shadow-sm'>
|
||||
<Card>
|
||||
<CardHeader className='pb-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<CardTitle className='text-xl'>Cashu Wallet Balance</CardTitle>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='space-y-1.5'>
|
||||
<CardTitle>Cashu Wallet Balance</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed balance breakdown by mint and currency
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex w-full gap-2 sm:w-auto'>
|
||||
<Button
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={() => setWithdrawModalOpen(true)}
|
||||
disabled={isLoading || !data || data.length === 0}
|
||||
className='flex-1 sm:flex-none'
|
||||
>
|
||||
<Wallet className='mr-2 h-4 w-4' />
|
||||
Withdraw
|
||||
@@ -87,7 +132,6 @@ export function DetailedWalletBalance({
|
||||
size='icon'
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading || isFetching}
|
||||
className='h-8 w-8'
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
@@ -99,177 +143,218 @@ export function DetailedWalletBalance({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Detailed balance breakdown by mint and currency
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<Loader2 className='text-primary h-8 w-8 animate-spin' />
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-3 md:grid-cols-3'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={`wallet-stat-skeleton-${index}`}>
|
||||
<CardHeader className='space-y-2 pb-1'>
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
<Skeleton className='h-3 w-8' />
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<Skeleton className='h-7 w-24' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-3 w-52' />
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`wallet-row-skeleton-${index}`}
|
||||
className='h-11 w-full'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className='bg-destructive/10 text-destructive flex items-center space-x-2 rounded-md p-4'>
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-5 w-5' />
|
||||
<span>Error loading balance: {(error as Error).message}</span>
|
||||
</div>
|
||||
<AlertDescription>
|
||||
Error loading balance: {(error as Error).message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Your Balance (Total)
|
||||
</span>
|
||||
<span className='text-2xl font-bold text-green-600'>
|
||||
{formatAmount(totals.totalOwner)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Wallet
|
||||
</span>
|
||||
<span className='text-lg font-semibold'>
|
||||
{formatAmount(totals.totalWallet)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
User Balance
|
||||
</span>
|
||||
<span className='text-lg font-semibold'>
|
||||
{formatAmount(totals.totalUser)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-3'>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-muted-foreground text-sm font-medium'>
|
||||
Your Balance (Total)
|
||||
</CardTitle>
|
||||
<span className='inline-flex size-8 items-center justify-center'>
|
||||
<Coins className='size-4 text-green-600 dark:text-green-300' />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-primary text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatAmount(totals.totalOwner)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-muted-foreground text-sm font-medium'>
|
||||
Total Wallet
|
||||
</CardTitle>
|
||||
<span className='inline-flex size-8 items-center justify-center'>
|
||||
<Wallet className='size-4 text-blue-600 dark:text-blue-300' />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatAmount(totals.totalWallet)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-muted-foreground text-sm font-medium'>
|
||||
User Balance
|
||||
</CardTitle>
|
||||
<span className='inline-flex size-8 items-center justify-center'>
|
||||
<User className='size-4 text-purple-600 dark:text-purple-300' />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatAmount(totals.totalUser)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Your balance = Total wallet - User balance
|
||||
</p>
|
||||
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
{/* Desktop Table Header */}
|
||||
<div className='bg-muted hidden grid-cols-4 gap-2 p-3 text-sm font-semibold md:grid'>
|
||||
<div>Mint / Unit</div>
|
||||
<div className='text-right'>Wallet</div>
|
||||
<div className='text-right'>Users</div>
|
||||
<div className='text-right'>Owner</div>
|
||||
</div>
|
||||
|
||||
{data && data.length > 0 ? (
|
||||
data
|
||||
.filter(
|
||||
(detail) =>
|
||||
(detail.wallet_balance && detail.wallet_balance > 0) ||
|
||||
detail.error
|
||||
)
|
||||
.map((detail, index) => {
|
||||
const walletMsat = convertToMsat(
|
||||
detail.wallet_balance || 0,
|
||||
detail.unit
|
||||
);
|
||||
const userMsat = convertToMsat(
|
||||
detail.user_balance || 0,
|
||||
detail.unit
|
||||
);
|
||||
const ownerMsat = convertToMsat(
|
||||
detail.owner_balance || 0,
|
||||
detail.unit
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'border-t p-3 text-sm',
|
||||
detail.error && 'bg-destructive/10 text-destructive'
|
||||
)}
|
||||
>
|
||||
{/* Desktop Layout */}
|
||||
<div className='hidden grid-cols-4 gap-2 md:grid'>
|
||||
<div className='text-xs break-all'>
|
||||
{detail.mint_url
|
||||
.replace('https://', '')
|
||||
.replace('http://', '')}{' '}
|
||||
• {detail.unit.toUpperCase()}
|
||||
</div>
|
||||
<div className='text-right font-mono'>
|
||||
{detail.error
|
||||
? 'error'
|
||||
: formatAmount(walletMsat)}
|
||||
</div>
|
||||
<div className='text-right font-mono'>
|
||||
{detail.error ? '-' : formatAmount(userMsat)}
|
||||
</div>
|
||||
<div
|
||||
{rows.length > 0 ? (
|
||||
<>
|
||||
<div className='hidden md:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Mint / Unit</TableHead>
|
||||
<TableHead className='text-right'>Wallet</TableHead>
|
||||
<TableHead className='text-right'>Users</TableHead>
|
||||
<TableHead className='text-right'>Owner</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map(
|
||||
({
|
||||
key,
|
||||
detail,
|
||||
walletMsat,
|
||||
userMsat,
|
||||
ownerMsat,
|
||||
}) => (
|
||||
<TableRow
|
||||
key={key}
|
||||
className={cn(
|
||||
'text-right font-mono',
|
||||
!detail.error &&
|
||||
ownerMsat > 0 &&
|
||||
'font-semibold text-green-600'
|
||||
detail.error &&
|
||||
'bg-destructive/10 text-destructive'
|
||||
)}
|
||||
>
|
||||
{detail.error ? '-' : formatAmount(ownerMsat)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Layout */}
|
||||
<div className='space-y-3 md:hidden'>
|
||||
<div className='space-y-1'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
Mint / Unit
|
||||
</span>
|
||||
<div className='font-mono text-xs break-all'>
|
||||
{detail.mint_url
|
||||
.replace('https://', '')
|
||||
.replace('http://', '')}{' '}
|
||||
• {detail.unit.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Wallet
|
||||
</div>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{detail.error
|
||||
? 'error'
|
||||
: formatAmount(walletMsat)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Users
|
||||
</div>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{detail.error ? '-' : formatAmount(userMsat)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Owner
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'truncate font-mono text-sm',
|
||||
!detail.error &&
|
||||
ownerMsat > 0 &&
|
||||
'font-semibold text-green-600'
|
||||
)}
|
||||
>
|
||||
{detail.error ? '-' : formatAmount(ownerMsat)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='text-muted-foreground p-4 text-center text-sm'>
|
||||
No balances to display
|
||||
<TableCell className='max-w-md font-mono text-xs break-all whitespace-normal'>
|
||||
{formatMintLabel(detail)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{detail.error
|
||||
? 'error'
|
||||
: formatAmount(walletMsat)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{detail.error ? '-' : formatAmount(userMsat)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
'text-right font-mono',
|
||||
!detail.error &&
|
||||
ownerMsat > 0 &&
|
||||
'text-primary font-semibold'
|
||||
)}
|
||||
>
|
||||
{detail.error ? '-' : formatAmount(ownerMsat)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2 md:hidden'>
|
||||
{rows.map(
|
||||
({ key, detail, walletMsat, userMsat, ownerMsat }) => (
|
||||
<Card
|
||||
key={`${key}-mobile`}
|
||||
className={cn(
|
||||
detail.error &&
|
||||
'border-destructive/40 bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2'>
|
||||
<CardDescription className='font-mono text-xs break-all'>
|
||||
{formatMintLabel(detail)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid grid-cols-1 gap-2 p-4 pt-0 sm:grid-cols-3 sm:gap-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Wallet
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{detail.error
|
||||
? 'error'
|
||||
: formatAmount(walletMsat)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Users
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{detail.error ? '-' : formatAmount(userMsat)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Owner
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'font-mono text-sm',
|
||||
!detail.error &&
|
||||
ownerMsat > 0 &&
|
||||
'text-primary font-semibold'
|
||||
)}
|
||||
>
|
||||
{detail.error ? '-' : formatAmount(ownerMsat)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className='py-6'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Wallet className='h-4 w-4' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No balances to display</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Wallet balances will appear here after funds are
|
||||
available.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2, Copy, Check, SendIcon } from 'lucide-react';
|
||||
import { Loader2, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
@@ -101,10 +101,7 @@ export function EcashRedeem() {
|
||||
return (
|
||||
<Card className='h-full w-full shadow-sm'>
|
||||
<CardHeader>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<SendIcon className='text-primary h-5 w-5' />
|
||||
<CardTitle>Send eCash</CardTitle>
|
||||
</div>
|
||||
<CardTitle>Send eCash</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a token to send eCash to someone
|
||||
</CardDescription>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Users, Key, Loader2, Globe, AlertTriangle, Info } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
@@ -122,9 +123,9 @@ export function EditGroupForm({
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Models using group API key:</span>
|
||||
<span className='font-medium text-blue-600'>
|
||||
<Badge variant='secondary' className='tabular-nums'>
|
||||
{modelsWithoutKeys}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,18 +139,18 @@ export function EditGroupForm({
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Models using group URL:</span>
|
||||
<span className='font-medium text-blue-600'>
|
||||
<Badge variant='secondary' className='tabular-nums'>
|
||||
{modelsUsingGroupUrl}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Models with individual URLs:</span>
|
||||
<span className='font-medium text-green-600'>
|
||||
<Badge variant='outline' className='tabular-nums'>
|
||||
{models.length - modelsUsingGroupUrl}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{groupSettings?.group_url && (
|
||||
<div className='mt-2 rounded bg-blue-50 p-2 text-xs'>
|
||||
<div className='bg-muted mt-2 rounded p-2 text-xs'>
|
||||
<span className='font-medium'>Current group URL:</span>{' '}
|
||||
{groupSettings.group_url}
|
||||
</div>
|
||||
@@ -163,26 +164,22 @@ export function EditGroupForm({
|
||||
<div className='max-h-32 overflow-y-auto'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{models.map((model) => (
|
||||
<span
|
||||
<Badge
|
||||
key={model.id}
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
model.api_key
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}
|
||||
variant={model.api_key ? 'default' : 'secondary'}
|
||||
className='max-w-full'
|
||||
title={
|
||||
model.api_key
|
||||
? 'Has individual API key and URL'
|
||||
: 'Uses group API key and URL'
|
||||
}
|
||||
>
|
||||
{model.name}
|
||||
{model.api_key && ' 🔑'}
|
||||
<span className='truncate'>{model.name}</span>
|
||||
{model.api_key && <Key className='ml-1 h-3 w-3' />}
|
||||
{model.url &&
|
||||
!model.url.startsWith('/') &&
|
||||
model.api_key &&
|
||||
' 🌐'}
|
||||
</span>
|
||||
model.api_key && <Globe className='ml-1 h-3 w-3' />}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,28 +307,30 @@ export function EditGroupForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='rounded-md border border-amber-200 bg-amber-50 p-4'>
|
||||
<p className='text-sm text-amber-800'>
|
||||
<strong>How group settings work:</strong>
|
||||
</p>
|
||||
<ul className='mt-2 list-inside list-disc space-y-1 text-sm text-amber-800'>
|
||||
<li>
|
||||
Models with individual API keys and URLs will keep their
|
||||
specific settings
|
||||
</li>
|
||||
<li>
|
||||
Models without individual settings will use the group defaults
|
||||
</li>
|
||||
<li>
|
||||
Removing the group URL makes models fall back to the system
|
||||
default endpoint
|
||||
</li>
|
||||
<li>
|
||||
You can use "Apply Group Settings" to force models
|
||||
to use group configurations
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Alert>
|
||||
<Info className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<p className='font-medium'>How group settings work:</p>
|
||||
<ul className='mt-2 list-inside list-disc space-y-1 text-sm'>
|
||||
<li>
|
||||
Models with individual API keys and URLs will keep their
|
||||
specific settings
|
||||
</li>
|
||||
<li>
|
||||
Models without individual settings will use the group
|
||||
defaults
|
||||
</li>
|
||||
<li>
|
||||
Removing the group URL makes models fall back to the system
|
||||
default endpoint
|
||||
</li>
|
||||
<li>
|
||||
You can use "Apply Group Settings" to force models
|
||||
to use group configurations
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button
|
||||
@@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { type Model } from '@/lib/api/schemas/models';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -32,9 +32,9 @@ import { Switch } from '@/components/ui/switch';
|
||||
const EditModelFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional(),
|
||||
context_length: z.coerce.number().min(0),
|
||||
prompt: z.coerce.number().min(0),
|
||||
completion: z.coerce.number().min(0),
|
||||
context_length: z.number().min(0),
|
||||
prompt: z.number().min(0),
|
||||
completion: z.number().min(0),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -47,6 +47,29 @@ const roundToFiveDecimals = (value: number | undefined | null): number => {
|
||||
return Math.round(value * 100000) / 100000;
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const toStringArray = (value: unknown, fallback: string[]): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const filtered = value.filter(
|
||||
(item): item is string => typeof item === 'string'
|
||||
);
|
||||
return filtered.length > 0 ? filtered : fallback;
|
||||
};
|
||||
|
||||
interface EditModelFormProps {
|
||||
model: Model;
|
||||
providerId?: number;
|
||||
@@ -82,6 +105,81 @@ interface AdminModelData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const normalizeAdminModelData = (
|
||||
adminModel: AdminModel,
|
||||
fallbackModel: Model,
|
||||
providerId: number
|
||||
): AdminModelData => {
|
||||
const pricingRecord =
|
||||
adminModel.pricing && typeof adminModel.pricing === 'object'
|
||||
? (adminModel.pricing as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const architectureRecord =
|
||||
adminModel.architecture && typeof adminModel.architecture === 'object'
|
||||
? (adminModel.architecture as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: adminModel.id,
|
||||
name: adminModel.name,
|
||||
description: adminModel.description || '',
|
||||
created: toNumber(adminModel.created, Math.floor(Date.now() / 1000)),
|
||||
context_length: Math.max(
|
||||
0,
|
||||
Math.trunc(
|
||||
toNumber(adminModel.context_length, fallbackModel.contextLength || 4096)
|
||||
)
|
||||
),
|
||||
architecture: {
|
||||
modality:
|
||||
typeof architectureRecord.modality === 'string'
|
||||
? architectureRecord.modality
|
||||
: fallbackModel.modelType || 'text',
|
||||
input_modalities: toStringArray(architectureRecord.input_modalities, [
|
||||
fallbackModel.modelType || 'text',
|
||||
]),
|
||||
output_modalities: toStringArray(architectureRecord.output_modalities, [
|
||||
fallbackModel.modelType || 'text',
|
||||
]),
|
||||
tokenizer:
|
||||
typeof architectureRecord.tokenizer === 'string'
|
||||
? architectureRecord.tokenizer
|
||||
: '',
|
||||
instruct_type:
|
||||
typeof architectureRecord.instruct_type === 'string'
|
||||
? architectureRecord.instruct_type
|
||||
: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: roundToFiveDecimals(
|
||||
toNumber(pricingRecord.prompt, fallbackModel.input_cost)
|
||||
),
|
||||
completion: roundToFiveDecimals(
|
||||
toNumber(pricingRecord.completion, fallbackModel.output_cost)
|
||||
),
|
||||
request: toNumber(pricingRecord.request, 0),
|
||||
image: toNumber(pricingRecord.image, 0),
|
||||
web_search: toNumber(pricingRecord.web_search, 0),
|
||||
internal_reasoning: toNumber(pricingRecord.internal_reasoning, 0),
|
||||
},
|
||||
per_request_limits:
|
||||
adminModel.per_request_limits === null ||
|
||||
adminModel.per_request_limits === undefined
|
||||
? adminModel.per_request_limits
|
||||
: null,
|
||||
top_provider:
|
||||
adminModel.top_provider === null || adminModel.top_provider === undefined
|
||||
? adminModel.top_provider
|
||||
: null,
|
||||
upstream_provider_id:
|
||||
typeof adminModel.upstream_provider_id === 'number'
|
||||
? adminModel.upstream_provider_id
|
||||
: providerId,
|
||||
enabled: adminModel.enabled !== false,
|
||||
};
|
||||
};
|
||||
|
||||
export function EditModelForm({
|
||||
model,
|
||||
providerId,
|
||||
@@ -114,29 +212,28 @@ export function EditModelForm({
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Loading admin model:', {
|
||||
providerId,
|
||||
modelId: model.full_name,
|
||||
});
|
||||
|
||||
const adminModel = await AdminService.getProviderModel(
|
||||
providerId,
|
||||
model.id
|
||||
);
|
||||
|
||||
setAdminModelData(adminModel as AdminModelData);
|
||||
const normalizedAdminModel = normalizeAdminModelData(
|
||||
adminModel,
|
||||
model,
|
||||
providerId
|
||||
);
|
||||
setAdminModelData(normalizedAdminModel);
|
||||
setIsNewOverride(false);
|
||||
|
||||
form.reset({
|
||||
name: adminModel.name,
|
||||
description: adminModel.description || '',
|
||||
context_length: adminModel.context_length,
|
||||
prompt: roundToFiveDecimals(adminModel.pricing.prompt),
|
||||
completion: roundToFiveDecimals(adminModel.pricing.completion),
|
||||
enabled: adminModel.enabled !== false,
|
||||
name: normalizedAdminModel.name,
|
||||
description: normalizedAdminModel.description || '',
|
||||
context_length: normalizedAdminModel.context_length,
|
||||
prompt: normalizedAdminModel.pricing.prompt,
|
||||
completion: normalizedAdminModel.pricing.completion,
|
||||
enabled: normalizedAdminModel.enabled !== false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.log('Model not in database, will create new override:', error);
|
||||
} catch {
|
||||
setIsNewOverride(true);
|
||||
setAdminModelData({
|
||||
id: model.full_name,
|
||||
@@ -238,11 +335,9 @@ export function EditModelForm({
|
||||
};
|
||||
|
||||
if (isNewOverride) {
|
||||
console.log('Creating new model override');
|
||||
await AdminService.createProviderModel(providerId, payload);
|
||||
toast.success('Model override created successfully!');
|
||||
} else {
|
||||
console.log('Updating existing model override');
|
||||
await AdminService.updateProviderModel(
|
||||
providerId,
|
||||
adminModelData.id,
|
||||
@@ -318,7 +413,19 @@ export function EditModelForm({
|
||||
type='number'
|
||||
min='0'
|
||||
placeholder='4096'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(
|
||||
value === '' ? 0 : parseInt(value, 10) || 0
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
field.onChange(
|
||||
Number.isNaN(value) ? 0 : Math.max(0, value)
|
||||
);
|
||||
}}
|
||||
className='w-full'
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -38,8 +38,8 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
|
||||
<CardTitle>Recent Errors ({errors.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='max-h-[400px] overflow-y-auto'>
|
||||
<Table>
|
||||
<div className='max-h-[420px] max-w-full overflow-y-auto'>
|
||||
<Table className='min-w-[640px] sm:min-w-[760px]'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -7,19 +7,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
};
|
||||
|
||||
type RefundReceipt = {
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
msats?: string;
|
||||
};
|
||||
import { WalletBalanceStats } from './wallet-balance-stats';
|
||||
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
|
||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||
|
||||
interface ApiKeyManagerProps {
|
||||
baseUrl: string;
|
||||
@@ -51,19 +41,31 @@ async function fetchWalletInfo(
|
||||
api_key: string;
|
||||
balance: number;
|
||||
reserved?: number;
|
||||
is_child: boolean;
|
||||
parent_key: string | null;
|
||||
total_requests: number;
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
balance_limit_reset: string | null;
|
||||
validity_date: number | null;
|
||||
child_keys?: ChildKeyInfo[];
|
||||
};
|
||||
|
||||
return {
|
||||
apiKey: payload.api_key || apiKey,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
isChild: payload.is_child,
|
||||
parentKey: payload.parent_key,
|
||||
totalRequests: payload.total_requests,
|
||||
totalSpent: payload.total_spent,
|
||||
balanceLimit: payload.balance_limit,
|
||||
balanceLimitReset: payload.balance_limit_reset,
|
||||
validityDate: payload.validity_date,
|
||||
childKeys: payload.child_keys,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
@@ -170,17 +172,14 @@ export function ApiKeyManager({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<RefreshCcw className='text-primary h-5 w-5' />
|
||||
API Key Management
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<CardTitle className='text-xl'>API Key Management</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide'>
|
||||
Manage your existing API keys and balances
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>Manage existing key</span>
|
||||
{walletInfo && (
|
||||
<span className='text-primary'>
|
||||
@@ -221,43 +220,15 @@ export function ApiKeyManager({
|
||||
|
||||
{showManageDetails && (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
Spendable
|
||||
</p>
|
||||
<p className='text-xl font-semibold'>
|
||||
{walletInfo
|
||||
? `${formatSats(walletInfo.balanceMsats)} sats`
|
||||
: '—'}
|
||||
</p>
|
||||
{walletInfo && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatMsats(walletInfo.balanceMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
Reserved
|
||||
</p>
|
||||
<p className='text-xl font-semibold'>
|
||||
{walletInfo
|
||||
? `${formatSats(walletInfo.reservedMsats)} sats`
|
||||
: '—'}
|
||||
</p>
|
||||
{walletInfo && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatMsats(walletInfo.reservedMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<WalletBalanceStats
|
||||
balanceMsats={walletInfo?.balanceMsats}
|
||||
reservedMsats={walletInfo?.reservedMsats}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>Refund remaining balance</span>
|
||||
</header>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
|
||||
@@ -1,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'>
|
||||
|
||||
@@ -2,38 +2,35 @@
|
||||
|
||||
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Bolt, Copy, RefreshCcw, ShieldCheck, Terminal } from 'lucide-react';
|
||||
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
|
||||
import {
|
||||
CashuPaymentWorkflow,
|
||||
type RefundReceipt,
|
||||
} from './cashu-payment-workflow';
|
||||
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
||||
import { ApiKeyManager } from './api-key-manager';
|
||||
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
|
||||
import { ChildKeyCreator } from '@/components/child-key-creator';
|
||||
|
||||
type NodeInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
npub?: string | null;
|
||||
mints: string[];
|
||||
http_url?: string | null;
|
||||
onion_url?: string | null;
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
http_url?: string;
|
||||
onion_url?: string;
|
||||
npub?: string;
|
||||
mints?: string[];
|
||||
child_key_cost_msats?: number;
|
||||
};
|
||||
|
||||
type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
};
|
||||
|
||||
type RefundReceipt = {
|
||||
token?: string;
|
||||
recipient?: string;
|
||||
sats?: string;
|
||||
@@ -170,7 +167,7 @@ export function CheatSheet(): JSX.Element {
|
||||
const refundToken = refundReceipt?.token ?? null;
|
||||
|
||||
return (
|
||||
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
|
||||
<div className='bg-background min-h-screen'>
|
||||
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
|
||||
<section className='relative space-y-3 text-center md:text-left'>
|
||||
<div className='absolute top-0 right-0 hidden md:block'>
|
||||
@@ -178,7 +175,7 @@ export function CheatSheet(): JSX.Element {
|
||||
<a href='/login'>Admin</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider'>
|
||||
<Bolt className='text-primary h-4 w-4' />
|
||||
Routstr cheat sheet
|
||||
</div>
|
||||
@@ -196,11 +193,8 @@ export function CheatSheet(): JSX.Element {
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<ShieldCheck className='text-primary h-4 w-4' />
|
||||
Node identity
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<CardTitle className='text-lg'>Node identity</CardTitle>
|
||||
<p className='text-muted-foreground text-xs tracking-wide'>
|
||||
/v1/info snapshot
|
||||
</p>
|
||||
</div>
|
||||
@@ -217,9 +211,44 @@ export function CheatSheet(): JSX.Element {
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{isInfoLoading && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Loading node profile…
|
||||
</p>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-8 w-56 max-w-full' />
|
||||
<Skeleton className='h-4 w-80 max-w-full' />
|
||||
</div>
|
||||
<dl className='grid gap-4 sm:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-3 w-16' />
|
||||
<Skeleton className='h-5 w-24' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-3 w-12' />
|
||||
<Skeleton className='h-5 w-40 max-w-full' />
|
||||
</div>
|
||||
<div className='space-y-2 sm:col-span-2'>
|
||||
<Skeleton className='h-3 w-12' />
|
||||
<Skeleton className='h-5 w-full max-w-[26rem]' />
|
||||
</div>
|
||||
<div className='space-y-2 sm:col-span-2'>
|
||||
<Skeleton className='h-3 w-10' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-5 max-w-[28rem] flex-1' />
|
||||
<Skeleton className='h-7 w-7 rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-3 w-20' />
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`mint-skeleton-${index}`}
|
||||
className='h-6 w-28 rounded-full'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isInfoError && !isInfoLoading && (
|
||||
<p className='text-destructive text-sm'>
|
||||
@@ -236,7 +265,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</div>
|
||||
<dl className='grid gap-4 sm:grid-cols-2'>
|
||||
<div>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide'>
|
||||
Version
|
||||
</dt>
|
||||
<dd className='text-base font-medium'>
|
||||
@@ -244,7 +273,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide'>
|
||||
HTTP
|
||||
</dt>
|
||||
<dd className='text-base font-medium break-all'>
|
||||
@@ -253,7 +282,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</div>
|
||||
{nodeInfo.onion_url && (
|
||||
<div className='sm:col-span-2'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide'>
|
||||
Onion
|
||||
</dt>
|
||||
<dd className='text-base font-medium break-all'>
|
||||
@@ -263,7 +292,7 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
{nodeInfo.npub && (
|
||||
<div className='sm:col-span-2'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<dt className='text-muted-foreground text-xs tracking-wide'>
|
||||
npub
|
||||
</dt>
|
||||
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
|
||||
@@ -281,11 +310,11 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
</dl>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<p className='text-muted-foreground text-xs tracking-wide'>
|
||||
Cashu mints
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{nodeInfo.mints.length ? (
|
||||
{nodeInfo.mints?.length ? (
|
||||
nodeInfo.mints.map((mint) => (
|
||||
<Badge
|
||||
key={mint}
|
||||
@@ -309,11 +338,8 @@ export function CheatSheet(): JSX.Element {
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<Terminal className='text-primary h-4 w-4' />
|
||||
Quick docs
|
||||
</CardTitle>
|
||||
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
|
||||
<CardTitle className='text-lg'>Quick docs</CardTitle>
|
||||
<span className='text-muted-foreground text-xs tracking-wide'>
|
||||
curl-ready
|
||||
</span>
|
||||
</CardHeader>
|
||||
@@ -334,11 +360,12 @@ export function CheatSheet(): JSX.Element {
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
|
||||
<pre className='break-all whitespace-pre-wrap'>
|
||||
<ScrollArea className='bg-muted w-full rounded-lg border'>
|
||||
<pre className='w-max min-w-full p-4 font-mono text-sm leading-6 whitespace-pre'>
|
||||
{curlSnippet}
|
||||
</pre>
|
||||
</div>
|
||||
<ScrollBar orientation='horizontal' />
|
||||
</ScrollArea>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
@@ -364,10 +391,11 @@ export function CheatSheet(): JSX.Element {
|
||||
</section>
|
||||
|
||||
<Tabs defaultValue='cashu' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
|
||||
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
|
||||
<TabsList className='grid w-full grid-cols-5'>
|
||||
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
|
||||
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
|
||||
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
||||
<TabsTrigger value='details'>Key Details</TabsTrigger>
|
||||
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -402,7 +430,7 @@ export function CheatSheet(): JSX.Element {
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2'>
|
||||
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>Cashu refund token</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -426,6 +454,16 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='details' className='space-y-4'>
|
||||
<KeyInfoDetails
|
||||
baseUrl={normalizedBaseUrl}
|
||||
apiKey={apiKeyInput}
|
||||
walletInfo={walletInfo}
|
||||
onApiKeyChanged={handleApiKeyChanged}
|
||||
onWalletInfoUpdated={handleWalletInfoUpdated}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='child-keys' className='space-y-4'>
|
||||
<ChildKeyCreator
|
||||
baseUrl={normalizedBaseUrl}
|
||||
|
||||
411
ui/components/landing/key-info-details.tsx
Normal file
411
ui/components/landing/key-info-details.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useState, useCallback, useEffect } from 'react';
|
||||
import { Copy, RefreshCcw, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { WalletService } from '@/lib/api/services/wallet';
|
||||
|
||||
export type ChildKeyInfo = {
|
||||
api_key: string;
|
||||
total_requests: number;
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
balance_limit_reset: string | null;
|
||||
validity_date: number | null;
|
||||
};
|
||||
|
||||
export type WalletSnapshot = {
|
||||
apiKey: string;
|
||||
balanceMsats: number;
|
||||
reservedMsats: number;
|
||||
isChild: boolean;
|
||||
parentKey: string | null;
|
||||
totalRequests: number;
|
||||
totalSpent: number;
|
||||
balanceLimit: number | null;
|
||||
balanceLimitReset: string | null;
|
||||
validityDate: number | null;
|
||||
childKeys?: ChildKeyInfo[];
|
||||
};
|
||||
|
||||
interface KeyInfoDetailsProps {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
walletInfo?: WalletSnapshot | null;
|
||||
onApiKeyChanged?: (apiKey: string) => void;
|
||||
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
|
||||
}
|
||||
|
||||
export function KeyInfoDetails({
|
||||
baseUrl,
|
||||
apiKey = '',
|
||||
walletInfo = null,
|
||||
onApiKeyChanged,
|
||||
onWalletInfoUpdated,
|
||||
}: KeyInfoDetailsProps): JSX.Element {
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState<string | null>(null);
|
||||
|
||||
// Sync internal state with props if they change
|
||||
useEffect(() => {
|
||||
setApiKeyInput(apiKey);
|
||||
}, [apiKey]);
|
||||
|
||||
const fetchDetails = useCallback(
|
||||
async (keyToFetch: string) => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||
headers: { Authorization: `Bearer ${keyToFetch}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch key info');
|
||||
}
|
||||
const payload = await response.json();
|
||||
const snapshot: WalletSnapshot = {
|
||||
apiKey: payload.api_key || keyToFetch,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
isChild: payload.is_child,
|
||||
parentKey: payload.parent_key,
|
||||
totalRequests: payload.total_requests,
|
||||
totalSpent: payload.total_spent,
|
||||
balanceLimit: payload.balance_limit,
|
||||
balanceLimitReset: payload.balance_limit_reset,
|
||||
validityDate: payload.validity_date,
|
||||
childKeys: payload.child_keys,
|
||||
};
|
||||
onWalletInfoUpdated?.(snapshot);
|
||||
toast.success('Key details synced');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to fetch details'
|
||||
);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
[baseUrl, onWalletInfoUpdated]
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!apiKeyInput) return;
|
||||
await fetchDetails(apiKeyInput);
|
||||
};
|
||||
|
||||
const handleKeyChange = (newKey: string) => {
|
||||
setApiKeyInput(newKey);
|
||||
onApiKeyChanged?.(newKey);
|
||||
// Optionally clear info when key changes
|
||||
if (newKey !== apiKey) {
|
||||
onWalletInfoUpdated?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const handleResetSpent = async (childKey: string) => {
|
||||
if (!walletInfo || walletInfo.isChild) return;
|
||||
|
||||
setIsResetting(childKey);
|
||||
try {
|
||||
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
|
||||
toast.success('Child key spent reset');
|
||||
await fetchDetails(apiKeyInput);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to reset child key'
|
||||
);
|
||||
} finally {
|
||||
setIsResetting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSats = (msats: number) =>
|
||||
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
const formatMsats = (msats: number) =>
|
||||
new Intl.NumberFormat('en-US').format(msats);
|
||||
const formatDate = (timestamp: number | null) =>
|
||||
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='text-xl'>Key Information</CardTitle>
|
||||
<CardDescription>
|
||||
Enter an API key to view its balance, consumption, and child keys.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-10 w-10 shrink-0'
|
||||
onClick={() => handleCopy(apiKeyInput)}
|
||||
disabled={!apiKeyInput}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='min-w-[80px] gap-1'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || !apiKeyInput}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isRefreshing ? 'Syncing...' : 'Sync'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{walletInfo && (
|
||||
<>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-lg'>Status & Identity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>Type</span>
|
||||
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
|
||||
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
|
||||
</Badge>
|
||||
</div>
|
||||
{walletInfo.parentKey && (
|
||||
<div className='space-y-1'>
|
||||
<span className='text-muted-foreground text-xs tracking-wider'>
|
||||
Parent Key
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
|
||||
{walletInfo.parentKey}
|
||||
</code>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() => handleCopy(walletInfo.parentKey!)}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Validity
|
||||
</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{formatDate(walletInfo.validityDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spendable Balance
|
||||
</span>
|
||||
<span className='text-primary font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.balanceMsats)} sats
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-lg'>Consumption</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Requests
|
||||
</span>
|
||||
<span className='font-mono text-sm font-medium'>
|
||||
{walletInfo.totalRequests}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Spent
|
||||
</span>
|
||||
<div className='text-right'>
|
||||
<p className='font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.totalSpent)} sats
|
||||
</p>
|
||||
<p className='text-muted-foreground font-mono text-[0.6rem]'>
|
||||
{formatMsats(walletInfo.totalSpent)} msats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{walletInfo.balanceLimit !== null && (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spend Limit
|
||||
</span>
|
||||
<span className='font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.balanceLimit)} sats
|
||||
</span>
|
||||
</div>
|
||||
{walletInfo.balanceLimitReset && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Reset Policy
|
||||
</span>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{walletInfo.balanceLimitReset}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{!walletInfo.isChild &&
|
||||
walletInfo.childKeys &&
|
||||
walletInfo.childKeys.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>
|
||||
Child Keys ({walletInfo.childKeys.length})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Secondary keys using this account's balance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{walletInfo.childKeys.map((ck) => (
|
||||
<div
|
||||
key={ck.api_key}
|
||||
className='space-y-3 rounded-lg border p-4'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
|
||||
{ck.api_key}
|
||||
</code>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() => handleCopy(ck.api_key)}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-destructive h-8 w-8'
|
||||
title='Reset consumption'
|
||||
disabled={isResetting === ck.api_key}
|
||||
onClick={() => handleResetSpent(ck.api_key)}
|
||||
>
|
||||
{isResetting === ck.api_key ? (
|
||||
<RefreshCcw className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RotateCcw className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4 text-xs sm:grid-cols-5'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Requests
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
{ck.total_requests}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Spent
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
{formatSats(ck.total_spent)} sats
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Limit
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
{ck.balance_limit
|
||||
? `${formatSats(ck.balance_limit)} sats`
|
||||
: 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Policy
|
||||
</p>
|
||||
<p className='font-medium capitalize'>
|
||||
{ck.balance_limit_reset || 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Expires
|
||||
</p>
|
||||
<p className='font-medium'>
|
||||
{formatDate(ck.validity_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className='text-muted-foreground'
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Last synced: {new Date().toLocaleTimeString()}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Copy, Zap, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { Copy, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import QRCode from 'qrcode';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -10,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>
|
||||
|
||||
63
ui/components/landing/wallet-balance-stats.tsx
Normal file
63
ui/components/landing/wallet-balance-stats.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface WalletBalanceStatsProps {
|
||||
balanceMsats?: number;
|
||||
reservedMsats?: number;
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
|
||||
function WalletMetricCard({
|
||||
label,
|
||||
valueMsats,
|
||||
}: {
|
||||
label: string;
|
||||
valueMsats?: number;
|
||||
}) {
|
||||
const hasValue = typeof valueMsats === 'number';
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='p-3 pb-2'>
|
||||
<CardDescription className='text-[0.65rem] tracking-wide'>
|
||||
{label}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='px-3 pt-0 pb-3'>
|
||||
<p className='text-xl font-semibold tabular-nums'>
|
||||
{hasValue ? `${formatSats(valueMsats)} sats` : '-'}
|
||||
</p>
|
||||
{hasValue && (
|
||||
<p className='text-muted-foreground text-xs tabular-nums'>
|
||||
{formatMsats(valueMsats)} msats
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function WalletBalanceStats({
|
||||
balanceMsats,
|
||||
reservedMsats,
|
||||
}: WalletBalanceStatsProps) {
|
||||
return (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<WalletMetricCard label='Spendable' valueMsats={balanceMsats} />
|
||||
<WalletMetricCard label='Reserved' valueMsats={reservedMsats} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
ui/components/model-item-card.tsx
Normal file
419
ui/components/model-item-card.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ArrowRight,
|
||||
AudioLines,
|
||||
Ban,
|
||||
CheckCircle,
|
||||
Edit3,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Layers3,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Type,
|
||||
Video,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ModelItemCardProps {
|
||||
model: Model;
|
||||
displayUnit: DisplayUnit;
|
||||
usdPerSat: number | null;
|
||||
isSelected: boolean;
|
||||
hasEffectiveApiKey: boolean;
|
||||
hasIndividualSettings: boolean;
|
||||
onHoverStart: () => void;
|
||||
onHoverEnd: () => void;
|
||||
onToggleSelection: () => void;
|
||||
onEdit: () => void;
|
||||
onOverride: () => void;
|
||||
onDisable: () => void;
|
||||
onEnable: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
type ModelModality =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'file'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'embedding'
|
||||
| 'multimodal';
|
||||
|
||||
const MODALITY_ORDER: ModelModality[] = [
|
||||
'text',
|
||||
'image',
|
||||
'file',
|
||||
'audio',
|
||||
'video',
|
||||
'embedding',
|
||||
'multimodal',
|
||||
];
|
||||
|
||||
function extractModalities(part: string): ModelModality[] {
|
||||
const normalized = part.toLowerCase();
|
||||
const modalities = MODALITY_ORDER.filter((modality) =>
|
||||
normalized.includes(modality)
|
||||
);
|
||||
|
||||
return modalities.length > 0 ? modalities : ['text'];
|
||||
}
|
||||
|
||||
function getModelTypeParts(modelType: string): {
|
||||
inputs: ModelModality[];
|
||||
outputs: ModelModality[];
|
||||
} {
|
||||
const [inputPart, outputPart] = modelType
|
||||
.split('->')
|
||||
.map((part) => part.trim());
|
||||
|
||||
return {
|
||||
inputs: extractModalities(inputPart || modelType),
|
||||
outputs: outputPart ? extractModalities(outputPart) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function ModelTypeIcons({ modelType }: { modelType: string }) {
|
||||
const { inputs, outputs } = getModelTypeParts(modelType);
|
||||
|
||||
const renderIcon = (modality: ModelModality, index: number) => {
|
||||
const props = {
|
||||
className: 'h-3 w-3 shrink-0',
|
||||
'aria-hidden': true as const,
|
||||
};
|
||||
|
||||
switch (modality) {
|
||||
case 'image':
|
||||
return <ImageIcon key={`${modality}-${index}`} {...props} />;
|
||||
case 'file':
|
||||
return <FileText key={`${modality}-${index}`} {...props} />;
|
||||
case 'audio':
|
||||
return <AudioLines key={`${modality}-${index}`} {...props} />;
|
||||
case 'video':
|
||||
return <Video key={`${modality}-${index}`} {...props} />;
|
||||
case 'embedding':
|
||||
return <Waypoints key={`${modality}-${index}`} {...props} />;
|
||||
case 'multimodal':
|
||||
return <Layers3 key={`${modality}-${index}`} {...props} />;
|
||||
case 'text':
|
||||
default:
|
||||
return <Type key={`${modality}-${index}`} {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className='text-muted-foreground inline-flex max-w-full min-w-0 items-center gap-0.5 overflow-hidden'
|
||||
title={modelType}
|
||||
aria-label={modelType}
|
||||
>
|
||||
{inputs.map(renderIcon)}
|
||||
{outputs.length > 0 && (
|
||||
<>
|
||||
<ArrowRight className='h-2.5 w-2.5 shrink-0 opacity-55' aria-hidden />
|
||||
{outputs.map(renderIcon)}
|
||||
</>
|
||||
)}
|
||||
<span className='sr-only'>{modelType}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelItemCard({
|
||||
model,
|
||||
displayUnit,
|
||||
usdPerSat,
|
||||
isSelected,
|
||||
hasEffectiveApiKey,
|
||||
hasIndividualSettings,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
onToggleSelection,
|
||||
onEdit,
|
||||
onOverride,
|
||||
onDisable,
|
||||
onEnable,
|
||||
onDelete,
|
||||
}: ModelItemCardProps) {
|
||||
const isDisabled = Boolean(model.soft_deleted);
|
||||
const statusLabel = isDisabled ? 'Disabled' : 'Enabled';
|
||||
const isFreeModel = model.is_free;
|
||||
const statusDotClass = cn(
|
||||
'inline-block h-2.5 w-2.5 shrink-0 rounded-full',
|
||||
isDisabled
|
||||
? 'bg-muted-foreground/50'
|
||||
: 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.14)]'
|
||||
);
|
||||
const modelSourceLabel =
|
||||
model.api_key_type === 'remote' ? 'Remote' : 'Database';
|
||||
const keySourceLabel = !hasEffectiveApiKey
|
||||
? 'No API key'
|
||||
: hasIndividualSettings
|
||||
? 'Individual key'
|
||||
: 'Group key';
|
||||
const inputPriceLabel = formatUsdAmountForDisplayUnit(
|
||||
model.input_cost,
|
||||
displayUnit,
|
||||
usdPerSat
|
||||
);
|
||||
const outputPriceLabel = formatUsdAmountForDisplayUnit(
|
||||
model.output_cost,
|
||||
displayUnit,
|
||||
usdPerSat
|
||||
);
|
||||
const pricingLabel = isFreeModel
|
||||
? 'Free'
|
||||
: `Input ${inputPriceLabel} · Output ${outputPriceLabel}`;
|
||||
const renderActionsMenu = (className: string) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={className}
|
||||
aria-label={`Model actions for ${model.name}`}
|
||||
title={`Model actions for ${model.name}`}
|
||||
>
|
||||
<MoreVertical className='text-muted-foreground hover:text-foreground h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-52'>
|
||||
{model.api_key_type !== 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{model.api_key_type === 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOverride();
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Override
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{model.soft_deleted ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEnable();
|
||||
}}
|
||||
className='text-foreground'
|
||||
>
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
Enable Model
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDisable();
|
||||
}}
|
||||
className='text-foreground'
|
||||
>
|
||||
<Ban className='mr-2 h-4 w-4' />
|
||||
Disable Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"after:bg-border/80 relative overflow-hidden rounded-none border-0 bg-transparent py-0 shadow-none ring-0 transition-colors duration-150 after:absolute after:right-3 after:bottom-0 after:left-3 after:h-px after:content-[''] last:after:hidden md:after:hidden",
|
||||
'hover:bg-muted/25',
|
||||
isSelected && 'bg-primary/12',
|
||||
model.soft_deleted && 'bg-muted/20 opacity-80'
|
||||
)}
|
||||
onMouseEnter={onHoverStart}
|
||||
onMouseLeave={onHoverEnd}
|
||||
>
|
||||
<div className='px-2 py-2 md:flex md:items-center md:gap-2 md:py-1'>
|
||||
<div className='hidden md:flex md:items-center'>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='border-border/90 data-checked:ring-primary/40 size-4 flex-shrink-0 data-checked:ring-2'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1 px-0.5 text-left'>
|
||||
<div className='hidden min-w-0 items-center gap-x-3 gap-y-1 md:grid md:grid-cols-[minmax(210px,1.45fr)_112px_96px_120px] lg:grid-cols-[minmax(230px,1.65fr)_128px_96px_120px_minmax(220px,1fr)]'>
|
||||
<div className='min-w-0'>
|
||||
<div
|
||||
className='flex min-w-0 items-center gap-2'
|
||||
title={statusLabel}
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<span className={statusDotClass} />
|
||||
<h3
|
||||
className={cn(
|
||||
'min-w-0 truncate text-sm font-medium',
|
||||
model.soft_deleted && 'text-muted-foreground'
|
||||
)}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className='sr-only'>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className='hidden min-w-0 overflow-hidden md:flex md:items-center'
|
||||
title={model.modelType}
|
||||
>
|
||||
<ModelTypeIcons modelType={model.modelType} />
|
||||
</span>
|
||||
<span
|
||||
className='text-muted-foreground hidden truncate text-xs md:block'
|
||||
title={modelSourceLabel}
|
||||
>
|
||||
{modelSourceLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground hidden truncate text-xs md:block',
|
||||
!hasEffectiveApiKey && 'text-destructive'
|
||||
)}
|
||||
title={keySourceLabel}
|
||||
>
|
||||
{keySourceLabel}
|
||||
</span>
|
||||
<span
|
||||
className='text-muted-foreground hidden truncate text-right text-xs whitespace-nowrap lg:block'
|
||||
title={pricingLabel}
|
||||
>
|
||||
{pricingLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[16px_minmax(0,1fr)] gap-x-2.5 gap-y-2.5 md:hidden'>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='border-border/90 data-checked:ring-primary/40 mt-0.5 size-4 flex-shrink-0 self-start data-checked:ring-2'
|
||||
/>
|
||||
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2'>
|
||||
<div
|
||||
className='flex min-w-0 items-center gap-2.5'
|
||||
title={statusLabel}
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<span className={statusDotClass} />
|
||||
<h3
|
||||
className={cn(
|
||||
'line-clamp-2 min-w-0 flex-1 text-[15px] leading-5 font-medium',
|
||||
model.soft_deleted && 'text-muted-foreground'
|
||||
)}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className='sr-only'>{statusLabel}</span>
|
||||
</div>
|
||||
{renderActionsMenu(
|
||||
'hover:bg-muted/50 dark:hover:bg-muted/80 -mr-1 h-7 w-7 shrink-0 self-start'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div aria-hidden />
|
||||
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px]'>
|
||||
<span
|
||||
className='text-foreground/80 inline-flex items-center'
|
||||
title={model.modelType}
|
||||
>
|
||||
<ModelTypeIcons modelType={model.modelType} />
|
||||
</span>
|
||||
<span className='text-border/80' aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className='inline-flex items-center'>
|
||||
{modelSourceLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center',
|
||||
!hasEffectiveApiKey && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
<span className='text-border/80 mr-2' aria-hidden>
|
||||
·
|
||||
</span>
|
||||
{keySourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div aria-hidden />
|
||||
{isFreeModel ? (
|
||||
<div className='text-muted-foreground text-sm font-medium'>
|
||||
Free
|
||||
</div>
|
||||
) : (
|
||||
<div className='border-border/45 bg-background/20 grid grid-cols-2 overflow-hidden rounded-lg border'>
|
||||
<div className='px-3 py-2.5'>
|
||||
<div className='text-muted-foreground text-[11px]'>Input</div>
|
||||
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
|
||||
{inputPriceLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-border/45 border-l px-3 py-2.5'>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
Output
|
||||
</div>
|
||||
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
|
||||
{outputPriceLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='hidden md:block'>
|
||||
{renderActionsMenu(
|
||||
'hover:bg-muted/50 dark:hover:bg-muted/80 h-7 w-7 shrink-0 md:h-6 md:w-6'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
243
ui/components/model-provider-section.tsx
Normal file
243
ui/components/model-provider-section.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import type { AdminModelGroup } from '@/lib/api/services/admin';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { ModelItemCard } from '@/components/model-item-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
CheckSquare,
|
||||
Edit3,
|
||||
Globe,
|
||||
Key,
|
||||
MoreVertical,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ModelProviderSectionProps {
|
||||
provider: string;
|
||||
providerModels: Model[];
|
||||
displayUnit: DisplayUnit;
|
||||
usdPerSat: number | null;
|
||||
filterProvider?: string;
|
||||
groupData?: AdminModelGroup;
|
||||
selectedModels: Set<string>;
|
||||
onSelectProviderModels: () => void;
|
||||
onDeselectProviderModels: () => void;
|
||||
onEditGroup: () => void;
|
||||
onRefreshProviderModels: () => void;
|
||||
onDeleteAllProviderModels: () => void;
|
||||
onModelHover: (modelId: string | null) => void;
|
||||
onModelToggleSelection: (modelId: string) => void;
|
||||
onEditModel: (model: Model) => void;
|
||||
onOverrideModel: (model: Model) => void;
|
||||
onEnableModel: (modelId: string) => void;
|
||||
onDisableModel: (modelId: string) => void;
|
||||
onDeleteModel: (modelId: string) => void;
|
||||
hasEffectiveApiKey: (model: Model) => boolean;
|
||||
hasIndividualSettings: (model: Model) => boolean;
|
||||
}
|
||||
|
||||
export function ModelProviderSection({
|
||||
provider,
|
||||
providerModels,
|
||||
displayUnit,
|
||||
usdPerSat,
|
||||
filterProvider,
|
||||
groupData,
|
||||
selectedModels,
|
||||
onSelectProviderModels,
|
||||
onDeselectProviderModels,
|
||||
onEditGroup,
|
||||
onRefreshProviderModels,
|
||||
onDeleteAllProviderModels,
|
||||
onModelHover,
|
||||
onModelToggleSelection,
|
||||
onEditModel,
|
||||
onOverrideModel,
|
||||
onEnableModel,
|
||||
onDisableModel,
|
||||
onDeleteModel,
|
||||
hasEffectiveApiKey,
|
||||
hasIndividualSettings,
|
||||
}: ModelProviderSectionProps) {
|
||||
const allProviderSelected = providerModels.every((model) =>
|
||||
selectedModels.has(model.id)
|
||||
);
|
||||
const someProviderSelected = providerModels.some((model) =>
|
||||
selectedModels.has(model.id)
|
||||
);
|
||||
const keyedProviderModels = useMemo(() => {
|
||||
const seenKeys = new Map<string, number>();
|
||||
|
||||
return providerModels.map((model) => {
|
||||
const baseKey = [
|
||||
provider,
|
||||
model.id,
|
||||
model.provider_id ?? '',
|
||||
model.full_name,
|
||||
model.api_key_type,
|
||||
].join('::');
|
||||
const seenCount = seenKeys.get(baseKey) ?? 0;
|
||||
seenKeys.set(baseKey, seenCount + 1);
|
||||
|
||||
return {
|
||||
model,
|
||||
renderKey:
|
||||
seenCount === 0 ? baseKey : `${baseKey}::duplicate-${seenCount}`,
|
||||
};
|
||||
});
|
||||
}, [provider, providerModels]);
|
||||
|
||||
if (filterProvider) {
|
||||
return (
|
||||
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
|
||||
{keyedProviderModels.map(({ model, renderKey }) => (
|
||||
<ModelItemCard
|
||||
key={renderKey}
|
||||
model={model}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
isSelected={selectedModels.has(model.id)}
|
||||
hasEffectiveApiKey={hasEffectiveApiKey(model)}
|
||||
hasIndividualSettings={hasIndividualSettings(model)}
|
||||
onHoverStart={() => onModelHover(model.id)}
|
||||
onHoverEnd={() => onModelHover(null)}
|
||||
onToggleSelection={() => onModelToggleSelection(model.id)}
|
||||
onEdit={() => onEditModel(model)}
|
||||
onOverride={() => onOverrideModel(model)}
|
||||
onDisable={() => onDisableModel(model.id)}
|
||||
onEnable={() => onEnableModel(model.id)}
|
||||
onDelete={() => onDeleteModel(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='px-3 pb-2.5 sm:px-6 sm:pb-3'>
|
||||
<div className='flex items-start justify-between gap-2 sm:gap-3'>
|
||||
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
|
||||
<Checkbox
|
||||
checked={
|
||||
allProviderSelected
|
||||
? true
|
||||
: someProviderSelected
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked === true) {
|
||||
onSelectProviderModels();
|
||||
return;
|
||||
}
|
||||
|
||||
onDeselectProviderModels();
|
||||
}}
|
||||
className='border-border/90 data-checked:ring-primary/35 mt-0.5 size-4 data-checked:ring-2 sm:mt-1 sm:size-5'
|
||||
aria-label={`Select models for provider ${provider}`}
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<CardTitle className='flex items-baseline gap-1.5 text-base sm:text-lg'>
|
||||
<span className='truncate'>{provider}</span>
|
||||
<span className='text-muted-foreground text-sm font-normal'>
|
||||
({providerModels.length} models)
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm'>
|
||||
{groupData?.group_url ? (
|
||||
<span className='inline-flex items-center gap-1 break-all'>
|
||||
<Globe className='h-3 w-3' />
|
||||
{groupData.group_url}
|
||||
</span>
|
||||
) : (
|
||||
'Using default endpoint'
|
||||
)}
|
||||
{groupData?.group_api_key && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Key className='h-3 w-3' />
|
||||
Group API Key
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7 sm:h-8 sm:w-8'
|
||||
aria-label={`Provider actions for ${provider}`}
|
||||
title={`Provider actions for ${provider}`}
|
||||
>
|
||||
<MoreVertical className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-64 sm:w-72'>
|
||||
<DropdownMenuItem onClick={onEditGroup}>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Group
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onSelectProviderModels}>
|
||||
<CheckSquare className='mr-2 h-4 w-4' />
|
||||
Select All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRefreshProviderModels}>
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
Refresh Models
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onDeleteAllProviderModels}
|
||||
className='text-muted-foreground focus:text-foreground'
|
||||
>
|
||||
Delete all overrides
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
|
||||
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
|
||||
{keyedProviderModels.map(({ model, renderKey }) => (
|
||||
<ModelItemCard
|
||||
key={renderKey}
|
||||
model={model}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
isSelected={selectedModels.has(model.id)}
|
||||
hasEffectiveApiKey={hasEffectiveApiKey(model)}
|
||||
hasIndividualSettings={hasIndividualSettings(model)}
|
||||
onHoverStart={() => onModelHover(model.id)}
|
||||
onHoverEnd={() => onModelHover(null)}
|
||||
onToggleSelection={() => onModelToggleSelection(model.id)}
|
||||
onEdit={() => onEditModel(model)}
|
||||
onOverride={() => onOverrideModel(model)}
|
||||
onDisable={() => onDisableModel(model.id)}
|
||||
onEnable={() => onEnableModel(model.id)}
|
||||
onDelete={() => onDeleteModel(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -97,17 +98,22 @@ export function ModelSearchFilter({
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim() !== '' || sortOption !== 'name-asc';
|
||||
const searchInputId = 'model-search-filter-input';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-3',
|
||||
'flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='relative flex-1'>
|
||||
<div className='relative w-full min-w-0 flex-1'>
|
||||
<Label htmlFor={searchInputId} className='sr-only'>
|
||||
Search models
|
||||
</Label>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
placeholder='Search models by name, provider, or description...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
@@ -115,32 +121,36 @@ export function ModelSearchFilter({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value: SortOption) => setSortOption(value)}
|
||||
>
|
||||
<SelectTrigger className='w-full sm:w-[180px]'>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
<SelectValue placeholder='Sort by' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='name-asc'>Name (A-Z)</SelectItem>
|
||||
<SelectItem value='name-desc'>Name (Z-A)</SelectItem>
|
||||
<SelectItem value='price-asc'>Price (Low to High)</SelectItem>
|
||||
<SelectItem value='price-desc'>Price (High to Low)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className='flex items-center gap-2 sm:shrink-0'>
|
||||
<div className='min-w-0 flex-1 sm:flex-none'>
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value: SortOption) => setSortOption(value)}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full sm:w-[170px]'>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
<SelectValue placeholder='Sort by' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='name-asc'>Name (A-Z)</SelectItem>
|
||||
<SelectItem value='name-desc'>Name (Z-A)</SelectItem>
|
||||
<SelectItem value='price-asc'>Price (Low to High)</SelectItem>
|
||||
<SelectItem value='price-desc'>Price (High to Low)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={clearFilters}
|
||||
className='h-9 w-9'
|
||||
className='h-8 shrink-0 px-2 sm:size-8 sm:px-0'
|
||||
aria-label='Clear model filters'
|
||||
title='Clear filters'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<X className='hidden h-4 w-4 sm:block' />
|
||||
<span className='text-xs sm:hidden'>Clear</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
1010
ui/components/model-selector.tsx
Normal file
1010
ui/components/model-selector.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
50
ui/components/page-header.tsx
Normal file
50
ui/components/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
ui/components/provider-balance.tsx
Normal file
316
ui/components/provider-balance.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
ui/components/provider-card.tsx
Normal file
158
ui/components/provider-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
ui/components/provider-form-dialog-content.tsx
Normal file
88
ui/components/provider-form-dialog-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
ui/components/provider-form-fields.tsx
Normal file
204
ui/components/provider-form-fields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
ui/components/provider-model-row.tsx
Normal file
46
ui/components/provider-model-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
ui/components/provider-models-panel.tsx
Normal file
168
ui/components/provider-models-panel.tsx
Normal 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'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'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
Reference in New Issue
Block a user