mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
80 Commits
feature/ze
...
add-missin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d48b36be8 | ||
|
|
8b3fdaa545 | ||
|
|
b43d57df23 | ||
|
|
b9a5a9276e | ||
|
|
a79fdf7212 | ||
|
|
9560050946 | ||
|
|
dd88e9b172 | ||
|
|
e31b45fa9e | ||
|
|
1ed8b29d64 | ||
|
|
59f8d31719 | ||
|
|
93a368b1a2 | ||
|
|
d3dd346853 | ||
|
|
0198569a9a | ||
|
|
7e648cb5c2 | ||
|
|
deb75624f3 | ||
|
|
1fa71ee806 | ||
|
|
97d164e181 | ||
|
|
9f3c915dec | ||
|
|
4ff6218140 | ||
|
|
67ea6aec43 | ||
|
|
41f346417a | ||
|
|
4857d741de | ||
|
|
eabe17cc26 | ||
|
|
8f298ae9c1 | ||
|
|
e5f3b7d755 | ||
|
|
3796e29cb4 | ||
|
|
812f50ea28 | ||
|
|
ce5fa136c2 | ||
|
|
4643aefb22 | ||
|
|
5aa902fd4a | ||
|
|
db144e0903 | ||
|
|
e20b20dbca | ||
|
|
aa700443d3 | ||
|
|
7e89b5bc6d | ||
|
|
8dbf036558 | ||
|
|
12f2cfecc9 | ||
|
|
a0f3378cf5 | ||
|
|
20b4c3a641 | ||
|
|
88f7ee734f | ||
|
|
326a0086b7 | ||
|
|
eb46651373 | ||
|
|
226188e22d | ||
|
|
89fc48eea2 | ||
|
|
f66d98e6b5 | ||
|
|
cfea4f9cd1 | ||
|
|
c74a877ab5 | ||
|
|
83a76e86fe | ||
|
|
66975ee271 | ||
|
|
5788d63892 | ||
|
|
d0c7cc6bd9 | ||
|
|
40096867d9 | ||
|
|
e002c0b66f | ||
|
|
608549d051 | ||
|
|
72f389c8d2 | ||
|
|
97daddb95a | ||
|
|
847f4b07a5 | ||
|
|
4bce04ada4 | ||
|
|
f1e2448620 | ||
|
|
28340a152c | ||
|
|
e28f6118e7 | ||
|
|
9fb6f54d12 | ||
|
|
3cb8d7b5dd | ||
|
|
ede076b881 | ||
|
|
449a0951f9 | ||
|
|
8c9ede2272 | ||
|
|
e794b09614 | ||
|
|
350714f23a | ||
|
|
9cc2e84f7c | ||
|
|
342a7f7f16 | ||
|
|
7694f20006 | ||
|
|
3ea0a267dd | ||
|
|
68e537f6fb | ||
|
|
814c39898d | ||
|
|
fdd4f12f5c | ||
|
|
6957d8c0d9 | ||
|
|
2c358276bf | ||
|
|
c5fd386c1e | ||
|
|
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
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add provider_settings to upstream_providers
|
||||
|
||||
Revision ID: 614c0a740e68
|
||||
Revises: 06f81c0fc88d
|
||||
Create Date: 2026-02-13 22:36:53.608737
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "614c0a740e68"
|
||||
down_revision = "06f81c0fc88d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if column exists before adding it
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [c["name"] for c in inspector.get_columns("upstream_providers")]
|
||||
|
||||
if "provider_settings" not in columns:
|
||||
op.add_column(
|
||||
"upstream_providers",
|
||||
sa.Column("provider_settings", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("upstream_providers", "provider_settings")
|
||||
@@ -118,6 +118,13 @@ def create_model_mappings(
|
||||
|
||||
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
|
||||
unique_models: dict[str, "Model"] = {}
|
||||
seen_model_provider: set[tuple[str, str]] = set()
|
||||
|
||||
providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {}
|
||||
for upstream in upstreams:
|
||||
db_id = getattr(upstream, "db_id", None)
|
||||
if isinstance(db_id, int):
|
||||
providers_by_db_id[db_id] = upstream
|
||||
|
||||
# Separate OpenRouter from other providers
|
||||
openrouter: "BaseUpstreamProvider" | None = None
|
||||
@@ -134,6 +141,16 @@ def create_model_mappings(
|
||||
"""Get base model ID by removing provider prefix."""
|
||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||
|
||||
def get_provider_identity(upstream: "BaseUpstreamProvider") -> str:
|
||||
"""Get a stable provider identity used for deduplication."""
|
||||
db_id = getattr(upstream, "db_id", None)
|
||||
if isinstance(db_id, int):
|
||||
return f"db:{db_id}"
|
||||
|
||||
provider_type = str(getattr(upstream, "provider_type", "") or "").lower()
|
||||
base_url = str(getattr(upstream, "base_url", "") or "").lower()
|
||||
return f"{provider_type}|{base_url}"
|
||||
|
||||
def _add_candidate(
|
||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||
) -> None:
|
||||
@@ -148,6 +165,7 @@ def create_model_mappings(
|
||||
) -> None:
|
||||
"""Process all models from a given provider."""
|
||||
upstream_prefix = getattr(upstream, "upstream_name", None)
|
||||
provider_key = get_provider_identity(upstream)
|
||||
|
||||
for model in upstream.get_cached_models():
|
||||
if not model.enabled or model.id in disabled_model_ids:
|
||||
@@ -189,6 +207,7 @@ def create_model_mappings(
|
||||
# Try to set each alias
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream)
|
||||
seen_model_provider.add((model_to_use.id.lower(), provider_key))
|
||||
|
||||
# Process non-OpenRouter providers first
|
||||
for upstream in other_upstreams:
|
||||
@@ -198,6 +217,85 @@ def create_model_mappings(
|
||||
if openrouter:
|
||||
process_provider_models(openrouter, is_openrouter=True)
|
||||
|
||||
# Include enabled DB overrides even when provider discovery misses models.
|
||||
# This is important for deployment-based providers like Azure.
|
||||
for model_id, override_data in overrides_by_id.items():
|
||||
if model_id in disabled_model_ids:
|
||||
continue
|
||||
override_row, provider_fee = override_data
|
||||
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
|
||||
if not isinstance(upstream_provider_id, int):
|
||||
continue
|
||||
|
||||
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
|
||||
if upstream_for_override is None:
|
||||
continue
|
||||
|
||||
provider_key = get_provider_identity(upstream_for_override)
|
||||
dedupe_key = (model_id.lower(), provider_key)
|
||||
if dedupe_key in seen_model_provider:
|
||||
continue
|
||||
|
||||
try:
|
||||
model_to_use = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Skipping invalid model override while building model mappings",
|
||||
extra={
|
||||
"model_id": model_id,
|
||||
"upstream_provider_id": upstream_provider_id,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
if not model_to_use.enabled:
|
||||
continue
|
||||
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
is_openrouter = (
|
||||
getattr(upstream_for_override, "base_url", "")
|
||||
== "https://openrouter.ai/api/v1"
|
||||
)
|
||||
if not is_openrouter or base_id not in unique_models:
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream_for_override.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
|
||||
try:
|
||||
aliases = resolve_model_alias(
|
||||
model_to_use.id,
|
||||
model_to_use.canonical_slug,
|
||||
alias_ids=model_to_use.alias_ids,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Skipping model aliases for invalid override model",
|
||||
extra={
|
||||
"model_id": model_id,
|
||||
"upstream_provider_id": upstream_provider_id,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
upstream_prefix = getattr(upstream_for_override, "upstream_name", None)
|
||||
if upstream_prefix and "/" not in model_to_use.id:
|
||||
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
|
||||
if prefixed_id not in aliases:
|
||||
aliases.append(prefixed_id)
|
||||
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream_for_override)
|
||||
seen_model_provider.add(dedupe_key)
|
||||
|
||||
# Sort candidates and build final maps
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
|
||||
@@ -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",
|
||||
@@ -997,18 +1007,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:
|
||||
|
||||
@@ -205,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(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
@@ -544,6 +545,7 @@ class UpstreamProviderCreate(BaseModel):
|
||||
api_version: str | None = None
|
||||
enabled: bool = True
|
||||
provider_fee: float = 1.01
|
||||
provider_settings: dict | None = None
|
||||
|
||||
|
||||
class UpstreamProviderUpdate(BaseModel):
|
||||
@@ -553,6 +555,7 @@ class UpstreamProviderUpdate(BaseModel):
|
||||
api_version: str | None = None
|
||||
enabled: bool | None = None
|
||||
provider_fee: float | None = None
|
||||
provider_settings: dict | None = None
|
||||
|
||||
|
||||
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
||||
@@ -569,6 +572,9 @@ async def get_upstream_providers() -> list[dict[str, object]]:
|
||||
"api_version": p.api_version,
|
||||
"enabled": p.enabled,
|
||||
"provider_fee": p.provider_fee,
|
||||
"provider_settings": json.loads(p.provider_settings)
|
||||
if p.provider_settings
|
||||
else None,
|
||||
}
|
||||
for p in providers
|
||||
]
|
||||
@@ -598,6 +604,9 @@ async def create_upstream_provider(
|
||||
api_version=payload.api_version,
|
||||
enabled=payload.enabled,
|
||||
provider_fee=payload.provider_fee,
|
||||
provider_settings=json.dumps(payload.provider_settings)
|
||||
if payload.provider_settings
|
||||
else None,
|
||||
)
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
@@ -613,6 +622,7 @@ async def create_upstream_provider(
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": payload.provider_settings,
|
||||
}
|
||||
|
||||
|
||||
@@ -632,6 +642,9 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]:
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -658,6 +671,8 @@ async def update_upstream_provider(
|
||||
provider.enabled = payload.enabled
|
||||
if payload.provider_fee is not None:
|
||||
provider.provider_fee = payload.provider_fee
|
||||
if payload.provider_settings is not None:
|
||||
provider.provider_settings = json.dumps(payload.provider_settings)
|
||||
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
@@ -673,6 +688,9 @@ async def update_upstream_provider(
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -730,9 +748,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": {
|
||||
@@ -794,6 +810,47 @@ class TopupRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
class TopupTokenRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup-token",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def topup_provider_with_token(
|
||||
provider_id: int, payload: TopupTokenRequest
|
||||
) -> dict:
|
||||
"""Redeem a Cashu token for an upstream provider."""
|
||||
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")
|
||||
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
headers = {}
|
||||
if provider.api_key:
|
||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||
resp = await client.post(
|
||||
f"{clean_url}/v1/balance/topup",
|
||||
json={"cashu_token": payload.token},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
return {"ok": True, "message": "Token redeemed successfully"}
|
||||
else:
|
||||
logger.error(f"Upstream token topup failed: {resp.text}")
|
||||
try:
|
||||
error_detail = resp.json()
|
||||
except Exception:
|
||||
error_detail = resp.text
|
||||
return {"ok": False, "message": f"Upstream error: {error_detail}"}
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -809,18 +866,84 @@ async def initiate_provider_topup(
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Initiating top-up for provider {provider_id}",
|
||||
extra={"amount": payload.amount},
|
||||
)
|
||||
|
||||
# For Routstr providers, we might be doing a Lightning top-up or a direct token transfer
|
||||
if provider.provider_type == "routstr":
|
||||
# UI sends sats for Routstr topup
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
request_json = {
|
||||
"amount_sats": int(payload.amount),
|
||||
"purpose": "topup",
|
||||
"api_key": provider.api_key,
|
||||
}
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {provider.api_key}"}
|
||||
if provider.api_key
|
||||
else {}
|
||||
)
|
||||
|
||||
last_status_code = 500
|
||||
last_error_detail: object = "Failed to create top-up invoice"
|
||||
|
||||
# Some upstream Routstr nodes fail the first invoice request after warm-up
|
||||
# and succeed immediately on retry. Retry once here so the UI stays single-click.
|
||||
for attempt in range(2):
|
||||
resp = await client.post(
|
||||
f"{clean_url}/v1/balance/lightning/invoice",
|
||||
json=request_json,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"topup_data": {
|
||||
"payment_request": data.get("bolt11"),
|
||||
"invoice_id": data.get("invoice_id"),
|
||||
"status": "pending",
|
||||
},
|
||||
}
|
||||
|
||||
logger.error(
|
||||
f"Upstream topup request failed: {resp.text}",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"attempt": attempt + 1,
|
||||
"status_code": resp.status_code,
|
||||
},
|
||||
)
|
||||
try:
|
||||
last_error_detail = resp.json()
|
||||
except Exception:
|
||||
last_error_detail = resp.text
|
||||
last_status_code = resp.status_code
|
||||
|
||||
if resp.status_code < 500 or attempt == 1:
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=last_status_code, detail=last_error_detail
|
||||
)
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||
|
||||
logger.info(
|
||||
"Top-up initiated successfully",
|
||||
extra={
|
||||
@@ -871,6 +994,23 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# For Routstr providers, proxy the status check
|
||||
if provider.provider_type == "routstr":
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
resp = await client.get(
|
||||
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
|
||||
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
status_data = resp.json()
|
||||
return {"ok": True, "paid": status_data.get("status") == "paid"}
|
||||
else:
|
||||
logger.error(f"Upstream status check failed: {resp.text}")
|
||||
return {"ok": False, "paid": False}
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
@@ -898,7 +1038,7 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Get the current account balance for the upstream provider."""
|
||||
"""Get the current balance for an upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
@@ -906,6 +1046,61 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# For Routstr providers, proxy the balance check
|
||||
if provider.provider_type == "routstr":
|
||||
import httpx
|
||||
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
headers = {}
|
||||
if provider.api_key:
|
||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{clean_url}/v1/balance/info",
|
||||
headers=headers,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
logger.error(
|
||||
"Timed out fetching Routstr provider balance",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"base_url": clean_url,
|
||||
"upstream_url": f"{clean_url}/v1/balance/info",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="Timed out contacting upstream Routstr provider",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
logger.error(
|
||||
"Failed to fetch Routstr provider balance",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"base_url": clean_url,
|
||||
"upstream_url": f"{clean_url}/v1/balance/info",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Failed to contact upstream Routstr provider",
|
||||
) from exc
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# Return balance in sats
|
||||
balance = data.get("balance", 0)
|
||||
if isinstance(balance, (int, float)):
|
||||
return {"ok": True, "balance_data": balance // 1000}
|
||||
return {"ok": True, "balance_data": balance}
|
||||
else:
|
||||
logger.error(f"Failed to fetch Routstr balance: {resp.text}")
|
||||
return {"ok": False, "balance_data": None}
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
@@ -942,9 +1137,7 @@ async def get_usage_metrics(
|
||||
interval: int = Query(
|
||||
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"
|
||||
),
|
||||
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
|
||||
) -> dict:
|
||||
"""Get usage metrics aggregated by time interval."""
|
||||
return log_manager.get_usage_metrics(interval=interval, hours=hours)
|
||||
@@ -953,9 +1146,7 @@ async def get_usage_metrics(
|
||||
@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"
|
||||
),
|
||||
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
|
||||
) -> dict:
|
||||
"""Get summary statistics for the specified time period."""
|
||||
return log_manager.get_usage_summary(hours=hours)
|
||||
@@ -964,9 +1155,7 @@ async def get_usage_summary(
|
||||
@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)])
|
||||
async def get_error_details(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
|
||||
limit: int = Query(
|
||||
default=100, ge=1, le=1000, description="Maximum number of errors to return"
|
||||
),
|
||||
@@ -980,9 +1169,7 @@ 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"
|
||||
),
|
||||
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
|
||||
limit: int = Query(
|
||||
default=20, ge=1, le=100, description="Maximum number of models to return"
|
||||
),
|
||||
@@ -1075,3 +1262,71 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
|
||||
continue
|
||||
|
||||
return {"dates": dates}
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/routstr/refund",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Refund balance from an upstream Routstr provider back to the local wallet."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.routstr import RoutstrUpstreamProvider
|
||||
|
||||
async with create_session() as session:
|
||||
provider_row = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider_row:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
if provider_row.provider_type != "routstr":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Refund only supported for Routstr providers"
|
||||
)
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if not isinstance(provider, RoutstrUpstreamProvider):
|
||||
raise HTTPException(status_code=400, detail="Invalid provider instance")
|
||||
|
||||
try:
|
||||
# Request refund from upstream
|
||||
data = await provider.refund_balance()
|
||||
if "error" in data:
|
||||
# If the upstream returned an OpenAI-style error (like the model unknown error)
|
||||
# it means the request likely didn't even reach the refund endpoint handler
|
||||
# but was intercepted by the proxy layer.
|
||||
error_info = data.get("error", {})
|
||||
message = (
|
||||
error_info.get("message")
|
||||
if isinstance(error_info, dict)
|
||||
else str(error_info)
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Upstream refund failed: {message}",
|
||||
}
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
return {"ok": False, "message": "Upstream did not return a token"}
|
||||
|
||||
# Receive token into local wallet
|
||||
from ..wallet import recieve_token
|
||||
|
||||
try:
|
||||
# Use current wallet to receive
|
||||
await recieve_token(token)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Successfully received refund from upstream provider",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to receive refund token: {e}")
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Failed to receive refund token: {str(e)}",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Refund failed for provider {provider_id}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -148,6 +148,9 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
provider_fee: float = Field(
|
||||
default=1.01, description="Provider fee multiplier (default 1%)"
|
||||
)
|
||||
provider_settings: str | None = Field(
|
||||
default=None, description="JSON string for provider-specific settings"
|
||||
)
|
||||
models: list["ModelRow"] = Relationship(
|
||||
back_populates="upstream_provider",
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
|
||||
@@ -19,6 +19,7 @@ class LogManager:
|
||||
specific_date: str | None = None,
|
||||
reverse_files: bool = False,
|
||||
max_files: int | None = None,
|
||||
window_center: datetime | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""
|
||||
Yields log entries from files.
|
||||
@@ -28,12 +29,14 @@ class LogManager:
|
||||
specific_date: specific date string (YYYY-MM-DD) to look at.
|
||||
reverse_files: if True, process files in reverse order (newest first).
|
||||
max_files: maximum number of log files to process (most recent if reverse_files is True).
|
||||
window_center: datetime object to center a 5-month window around.
|
||||
"""
|
||||
if not self.logs_dir.exists():
|
||||
return
|
||||
|
||||
log_files = []
|
||||
cutoff_date = None
|
||||
cutoff_timestamp_str: str | None = None
|
||||
|
||||
if specific_date:
|
||||
log_file = self.logs_dir / f"app_{specific_date}.log"
|
||||
@@ -41,12 +44,43 @@ class LogManager:
|
||||
log_files.append(log_file)
|
||||
else:
|
||||
log_files = sorted(self.logs_dir.glob("app_*.log"))
|
||||
|
||||
if window_center:
|
||||
# Calculate the 5 months: [center-2, center-1, center, center+1, center+2]
|
||||
allowed_month_years = []
|
||||
cur_m = window_center.month
|
||||
cur_y = window_center.year
|
||||
|
||||
for offset in range(-2, 3):
|
||||
m = cur_m + offset
|
||||
y = cur_y
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
while m > 12:
|
||||
m -= 12
|
||||
y += 1
|
||||
allowed_month_years.append(f"{y}-{m:02d}")
|
||||
|
||||
filtered_files = []
|
||||
for log_path in log_files:
|
||||
try:
|
||||
# Stem is "app_YYYY-MM-DD"
|
||||
file_date_str = log_path.stem.split("_")[1]
|
||||
file_month_year = file_date_str[:7] # YYYY-MM
|
||||
if file_month_year in allowed_month_years:
|
||||
filtered_files.append(log_path)
|
||||
except Exception:
|
||||
continue
|
||||
log_files = filtered_files
|
||||
|
||||
if reverse_files:
|
||||
log_files.reverse()
|
||||
|
||||
# If we only care about hours back, we can optimize file selection
|
||||
if hours_back is not None:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
||||
cutoff_timestamp_str = cutoff_date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
filtered_files = []
|
||||
for log_path in log_files:
|
||||
try:
|
||||
@@ -69,27 +103,20 @@ class LogManager:
|
||||
for log_file in log_files:
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
# For reverse search, we might want to read lines in reverse?
|
||||
# But usually logs are append-only.
|
||||
# If reverse_files is True, we iterate files newest to oldest.
|
||||
# But lines within file are still oldest to newest unless we reverse them.
|
||||
lines = f.readlines()
|
||||
if reverse_files:
|
||||
lines.reverse()
|
||||
lines_iter = reversed(f.readlines()) if reverse_files else f
|
||||
|
||||
for line in lines:
|
||||
for line in lines_iter:
|
||||
try:
|
||||
entry = json.loads(line.strip())
|
||||
|
||||
if cutoff_date:
|
||||
if cutoff_timestamp_str:
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
if not timestamp_str:
|
||||
if (
|
||||
not isinstance(timestamp_str, str)
|
||||
or len(timestamp_str) != 19
|
||||
):
|
||||
continue
|
||||
log_time = datetime.strptime(
|
||||
timestamp_str, "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
log_time = log_time.replace(tzinfo=timezone.utc)
|
||||
if log_time < cutoff_date:
|
||||
if timestamp_str < cutoff_timestamp_str:
|
||||
continue
|
||||
|
||||
yield entry
|
||||
@@ -166,7 +193,7 @@ class LogManager:
|
||||
methods: list[str] | None = None,
|
||||
endpoints: list[str] | None = None,
|
||||
) -> bool:
|
||||
if level and log_data.get("levelname", "").upper() != level.upper():
|
||||
if level and str(log_data.get("levelname", "")).upper() != level.upper():
|
||||
return False
|
||||
|
||||
if request_id and log_data.get("request_id") != request_id:
|
||||
@@ -216,48 +243,108 @@ class LogManager:
|
||||
|
||||
return True
|
||||
|
||||
def _bucket_key_for_timestamp(
|
||||
self, timestamp_str: str, interval_minutes: int
|
||||
) -> str | None:
|
||||
if len(timestamp_str) != 19:
|
||||
return None
|
||||
if timestamp_str[10] != " ":
|
||||
return None
|
||||
|
||||
try:
|
||||
hour = int(timestamp_str[11:13])
|
||||
minute = int(timestamp_str[14:16])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
total_minutes = hour * 60 + minute
|
||||
rounded_minutes = (total_minutes // interval_minutes) * interval_minutes
|
||||
rounded_hour = rounded_minutes // 60
|
||||
rounded_minute = rounded_minutes % 60
|
||||
return f"{timestamp_str[:10]} {rounded_hour:02d}:{rounded_minute:02d}:00"
|
||||
|
||||
def _extract_success_metrics(
|
||||
self, entry: dict[str, Any], message: str
|
||||
) -> tuple[bool, float, int, int]:
|
||||
# Use auth settlement logs as the canonical successful request signal.
|
||||
logger_name = str(entry.get("name", ""))
|
||||
if not logger_name.startswith("routstr.auth"):
|
||||
return False, 0.0, 0, 0
|
||||
|
||||
input_tokens = self._parse_token_count(entry.get("input_tokens", 0))
|
||||
output_tokens = self._parse_token_count(entry.get("output_tokens", 0))
|
||||
|
||||
if "calculated token-based cost" in message:
|
||||
token_cost = entry.get("token_cost", 0)
|
||||
if isinstance(token_cost, (int, float)) and token_cost > 0:
|
||||
return True, float(token_cost), input_tokens, output_tokens
|
||||
return True, 0.0, input_tokens, output_tokens
|
||||
|
||||
if "max cost payment finalized" in message:
|
||||
charged_amount = entry.get("charged_amount", 0)
|
||||
if isinstance(charged_amount, (int, float)) and charged_amount > 0:
|
||||
return True, float(charged_amount), input_tokens, output_tokens
|
||||
return True, 0.0, input_tokens, output_tokens
|
||||
|
||||
return False, 0.0, 0, 0
|
||||
|
||||
def _parse_token_count(self, value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if isinstance(value, float):
|
||||
return max(0, int(value))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
def get_usage_summary(self, hours: int = 24) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
entries = list(
|
||||
self._yield_log_entries(
|
||||
hours_back=hours, window_center=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
return self._calculate_summary_stats(entries)
|
||||
|
||||
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
entries = list(
|
||||
self._yield_log_entries(
|
||||
hours_back=hours, window_center=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
return self._aggregate_metrics_by_time(entries, interval, hours)
|
||||
|
||||
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
|
||||
errors: list[dict] = []
|
||||
# Iterate newest to oldest for errors?
|
||||
# yield_log_entries sorts files by name (date) ascending by default.
|
||||
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
|
||||
# For error details "last N errors", we probably want newest first.
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
# Using list() loads everything into memory, which is what PR 229 did.
|
||||
# For optimization, we could use reverse iterator.
|
||||
for entry in self._yield_log_entries(hours_back=hours):
|
||||
if str(entry.get("levelname", "")).upper() != "ERROR":
|
||||
continue
|
||||
|
||||
# Let's just stick to PR 229 logic which filters 'ERROR' level.
|
||||
errors.append(
|
||||
{
|
||||
"timestamp": entry.get("asctime", ""),
|
||||
"message": entry.get("message", ""),
|
||||
"error_type": entry.get("error_type", "unknown"),
|
||||
"pathname": entry.get("pathname", ""),
|
||||
"lineno": entry.get("lineno", 0),
|
||||
"request_id": entry.get("request_id", ""),
|
||||
}
|
||||
)
|
||||
|
||||
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
|
||||
|
||||
for entry in entries:
|
||||
if entry.get("levelname", "").upper() == "ERROR":
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
errors.append(
|
||||
{
|
||||
"timestamp": timestamp_str,
|
||||
"message": entry.get("message", ""),
|
||||
"error_type": entry.get("error_type", "unknown"),
|
||||
"pathname": entry.get("pathname", ""),
|
||||
"lineno": entry.get("lineno", 0),
|
||||
"request_id": entry.get("request_id", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort reverse time
|
||||
errors.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
errors.sort(key=lambda x: str(x["timestamp"]), reverse=True)
|
||||
return {"errors": errors[:limit], "total_count": len(errors)}
|
||||
|
||||
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
entries = list(
|
||||
self._yield_log_entries(
|
||||
hours_back=hours, window_center=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
|
||||
model_stats: dict[str, dict[str, int | float]] = defaultdict(
|
||||
lambda: {
|
||||
@@ -275,23 +362,22 @@ class LogManager:
|
||||
if not isinstance(model, str):
|
||||
model = "unknown"
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
message = str(entry.get("message", "")).lower()
|
||||
|
||||
if "received proxy request" in message:
|
||||
completed, revenue_msats, _, _ = self._extract_success_metrics(
|
||||
entry, message
|
||||
)
|
||||
if completed:
|
||||
model_stats[model]["requests"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
model_stats[model]["successful"] += 1
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
model_stats[model]["revenue_msats"] += actual_cost
|
||||
if revenue_msats > 0:
|
||||
model_stats[model]["revenue_msats"] += revenue_msats
|
||||
|
||||
if "revert payment" in message or "upstream request failed" in message:
|
||||
failed = (
|
||||
"revert payment" in message or "upstream request failed" in message
|
||||
)
|
||||
if failed:
|
||||
model_stats[model]["requests"] += 1
|
||||
model_stats[model]["failed"] += 1
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
@@ -340,83 +426,16 @@ class LogManager:
|
||||
"total_models": len(models),
|
||||
}
|
||||
|
||||
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
|
||||
stats: dict[str, Any] = {
|
||||
"total_entries": 0,
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"total_errors": 0,
|
||||
"total_warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"unique_models": set(),
|
||||
"error_types": defaultdict(int),
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
stats["total_entries"] += 1
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
level = entry.get("levelname", "").upper()
|
||||
|
||||
if level == "ERROR":
|
||||
stats["total_errors"] += 1
|
||||
if "error_type" in entry:
|
||||
stats["error_types"][str(entry["error_type"])] += 1
|
||||
elif level == "WARNING":
|
||||
stats["total_warnings"] += 1
|
||||
|
||||
if "received proxy request" in message:
|
||||
stats["total_requests"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
stats["successful_chat_completions"] += 1
|
||||
|
||||
if "upstream request failed" in message or "revert payment" in message:
|
||||
stats["failed_requests"] += 1
|
||||
|
||||
if "payment processed successfully" in message:
|
||||
stats["payment_processed"] += 1
|
||||
|
||||
if "upstream" in message and level == "ERROR":
|
||||
stats["upstream_errors"] += 1
|
||||
|
||||
if "model" in entry:
|
||||
model = entry["model"]
|
||||
if isinstance(model, str) and model != "unknown":
|
||||
stats["unique_models"].add(model)
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
stats["revenue_msats"] += float(actual_cost)
|
||||
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
stats["refunds_msats"] += float(max_cost)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _build_summary_response(self, stats: dict[str, Any]) -> dict[str, Any]:
|
||||
revenue_sats = stats["revenue_msats"] / 1000
|
||||
refunds_sats = stats["refunds_msats"] / 1000
|
||||
net_revenue_sats = revenue_sats - refunds_sats
|
||||
|
||||
total_requests = stats["total_requests"]
|
||||
successful = stats["successful_chat_completions"]
|
||||
input_tokens = stats["input_tokens"]
|
||||
output_tokens = stats["output_tokens"]
|
||||
total_tokens = stats["total_tokens"]
|
||||
|
||||
return {
|
||||
"total_entries": stats["total_entries"],
|
||||
@@ -430,6 +449,18 @@ class LogManager:
|
||||
"unique_models_count": len(stats["unique_models"]),
|
||||
"unique_models": sorted(list(stats["unique_models"])),
|
||||
"error_types": dict(stats["error_types"]),
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"avg_input_tokens_per_completion": (
|
||||
input_tokens / successful if successful > 0 else 0
|
||||
),
|
||||
"avg_output_tokens_per_completion": (
|
||||
output_tokens / successful if successful > 0 else 0
|
||||
),
|
||||
"avg_total_tokens_per_completion": (
|
||||
total_tokens / successful if successful > 0 else 0
|
||||
),
|
||||
"success_rate": (successful / total_requests * 100)
|
||||
if total_requests > 0
|
||||
else 0,
|
||||
@@ -449,62 +480,198 @@ class LogManager:
|
||||
),
|
||||
}
|
||||
|
||||
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
|
||||
stats: dict[str, Any] = {
|
||||
"total_entries": 0,
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"total_errors": 0,
|
||||
"total_warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"unique_models": set(),
|
||||
"error_types": defaultdict(int),
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
stats["total_entries"] += 1
|
||||
|
||||
message = str(entry.get("message", "")).lower()
|
||||
level = str(entry.get("levelname", "")).upper()
|
||||
|
||||
if level == "ERROR":
|
||||
stats["total_errors"] += 1
|
||||
if "error_type" in entry:
|
||||
stats["error_types"][str(entry["error_type"])] += 1
|
||||
elif level == "WARNING":
|
||||
stats["total_warnings"] += 1
|
||||
|
||||
completed, revenue_msats, input_tokens, output_tokens = (
|
||||
self._extract_success_metrics(entry, message)
|
||||
)
|
||||
if completed:
|
||||
stats["total_requests"] += 1
|
||||
stats["successful_chat_completions"] += 1
|
||||
stats["input_tokens"] += input_tokens
|
||||
stats["output_tokens"] += output_tokens
|
||||
stats["total_tokens"] += input_tokens + output_tokens
|
||||
|
||||
failed = (
|
||||
"upstream request failed" in message
|
||||
or "revert payment" in message
|
||||
)
|
||||
if failed:
|
||||
stats["total_requests"] += 1
|
||||
stats["failed_requests"] += 1
|
||||
|
||||
if "payment processed successfully" in message:
|
||||
stats["payment_processed"] += 1
|
||||
|
||||
if "upstream" in message and level == "ERROR":
|
||||
stats["upstream_errors"] += 1
|
||||
|
||||
if "model" in entry:
|
||||
model = entry["model"]
|
||||
if isinstance(model, str) and model != "unknown":
|
||||
stats["unique_models"].add(model)
|
||||
|
||||
if completed and revenue_msats > 0:
|
||||
stats["revenue_msats"] += revenue_msats
|
||||
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
stats["refunds_msats"] += float(max_cost)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return self._build_summary_response(stats)
|
||||
|
||||
def _aggregate_metrics_by_time(
|
||||
self, entries: list[dict], interval_minutes: int, hours_back: int
|
||||
) -> dict:
|
||||
time_buckets: dict[str, dict[str, Any]] = defaultdict(
|
||||
lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0}
|
||||
lambda: {
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
if not timestamp_str:
|
||||
if not isinstance(timestamp_str, str):
|
||||
continue
|
||||
|
||||
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
log_time = log_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Round down to nearest interval
|
||||
minutes = log_time.minute
|
||||
rounded_minutes = (minutes // interval_minutes) * interval_minutes
|
||||
bucket_time = log_time.replace(
|
||||
minute=rounded_minutes, second=0, microsecond=0
|
||||
bucket_key = self._bucket_key_for_timestamp(
|
||||
timestamp_str, interval_minutes
|
||||
)
|
||||
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if not bucket_key:
|
||||
continue
|
||||
|
||||
bucket = time_buckets[bucket_key]
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
level = entry.get("levelname", "").upper()
|
||||
message = str(entry.get("message", "")).lower()
|
||||
level = str(entry.get("levelname", "")).upper()
|
||||
|
||||
if "received proxy request" in message:
|
||||
bucket["requests"] += 1
|
||||
completed, revenue_msats, input_tokens, output_tokens = (
|
||||
self._extract_success_metrics(entry, message)
|
||||
)
|
||||
if completed:
|
||||
bucket["total_requests"] += 1
|
||||
bucket["successful_chat_completions"] += 1
|
||||
bucket["input_tokens"] += input_tokens
|
||||
bucket["output_tokens"] += output_tokens
|
||||
bucket["total_tokens"] += input_tokens + output_tokens
|
||||
|
||||
if level == "ERROR":
|
||||
bucket["errors"] += 1
|
||||
if "upstream" in message:
|
||||
bucket["upstream_errors"] += 1
|
||||
elif level == "WARNING":
|
||||
bucket["warnings"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
bucket["revenue_msats"] += float(actual_cost)
|
||||
failed = (
|
||||
"upstream request failed" in message
|
||||
or "revert payment" in message
|
||||
)
|
||||
if failed:
|
||||
bucket["total_requests"] += 1
|
||||
bucket["failed_requests"] += 1
|
||||
|
||||
if "payment processed successfully" in message:
|
||||
bucket["payment_processed"] += 1
|
||||
|
||||
if completed and revenue_msats > 0:
|
||||
bucket["revenue_msats"] += revenue_msats
|
||||
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
bucket["refunds_msats"] += float(max_cost)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
result = []
|
||||
for bucket_key in sorted(time_buckets.keys()):
|
||||
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
|
||||
bucket = dict(time_buckets[bucket_key])
|
||||
# Backward-compatible alias for any callers still reading "requests".
|
||||
bucket["requests"] = bucket["total_requests"]
|
||||
result.append({"timestamp": bucket_key, **bucket})
|
||||
|
||||
totals = {
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
for bucket in result:
|
||||
totals["total_requests"] += int(bucket["total_requests"])
|
||||
totals["successful_chat_completions"] += int(
|
||||
bucket["successful_chat_completions"]
|
||||
)
|
||||
totals["failed_requests"] += int(bucket["failed_requests"])
|
||||
totals["errors"] += int(bucket["errors"])
|
||||
totals["warnings"] += int(bucket["warnings"])
|
||||
totals["payment_processed"] += int(bucket["payment_processed"])
|
||||
totals["upstream_errors"] += int(bucket["upstream_errors"])
|
||||
totals["revenue_msats"] += float(bucket["revenue_msats"])
|
||||
totals["refunds_msats"] += float(bucket["refunds_msats"])
|
||||
totals["input_tokens"] += int(bucket["input_tokens"])
|
||||
totals["output_tokens"] += int(bucket["output_tokens"])
|
||||
totals["total_tokens"] += int(bucket["total_tokens"])
|
||||
|
||||
return {
|
||||
"metrics": result,
|
||||
"interval_minutes": interval_minutes,
|
||||
"hours_back": hours_back,
|
||||
"total_buckets": len(result),
|
||||
"totals": totals,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..nostr.discovery import providers_router
|
||||
from ..payment.models import models_router, update_sats_pricing
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..upstream.auto_topup import periodic_auto_topup
|
||||
from ..wallet import periodic_payout
|
||||
from .admin import admin_router
|
||||
from .db import create_session, init_db, run_migrations
|
||||
@@ -48,6 +49,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
auto_topup_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
@@ -104,6 +106,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
if global_settings.providers_refresh_interval_seconds > 0:
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
key_reset_task = asyncio.create_task(periodic_key_reset())
|
||||
auto_topup_task = asyncio.create_task(periodic_auto_topup())
|
||||
|
||||
yield
|
||||
|
||||
@@ -135,6 +138,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
model_maps_refresh_task.cancel()
|
||||
if key_reset_task is not None:
|
||||
key_reset_task.cancel()
|
||||
if auto_topup_task is not None:
|
||||
auto_topup_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
@@ -154,6 +159,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
if key_reset_task is not None:
|
||||
tasks_to_wait.append(key_reset_task)
|
||||
if auto_topup_task is not None:
|
||||
tasks_to_wait.append(auto_topup_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
|
||||
@@ -52,7 +52,7 @@ class Settings(BaseSettings):
|
||||
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
||||
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
|
||||
child_key_cost: int = Field(default=0, env="CHILD_KEY_COST")
|
||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
||||
reset_reserved_balance_on_startup: bool = Field(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -138,11 +138,6 @@ async def proxy(
|
||||
) -> Response | StreamingResponse:
|
||||
headers = dict(request.headers)
|
||||
|
||||
if "x-cashu" not in headers and "authorization" not in headers.keys():
|
||||
return create_error_response(
|
||||
"unauthorized", "Unauthorized", 401, request=request
|
||||
)
|
||||
|
||||
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
|
||||
request_body = await request.body()
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
@@ -153,6 +148,7 @@ async def proxy(
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
model_obj = get_model_instance(model_id)
|
||||
|
||||
if not model_obj:
|
||||
return create_error_response(
|
||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||
@@ -300,9 +296,13 @@ async def proxy(
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
except UpstreamError:
|
||||
# Let the outer UpstreamError handler manage retry/revert
|
||||
raise
|
||||
except Exception as e:
|
||||
# Unexpected error (not an upstream failure) — revert and propagate
|
||||
logger.error(
|
||||
"Upstream request failed, ensuring payment is reverted",
|
||||
"Unexpected error in upstream request, reverting payment",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
@@ -390,7 +390,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)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
@@ -24,6 +25,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
RoutstrUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
157
routstr/upstream/auto_topup.py
Normal file
157
routstr/upstream/auto_topup.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import UpstreamProviderRow, create_session
|
||||
from ..wallet import send_token
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Check every 60 seconds
|
||||
AUTO_TOPUP_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
async def periodic_auto_topup() -> None:
|
||||
"""Background task that monitors Routstr provider balances and auto-tops up when below threshold.
|
||||
|
||||
For each Routstr provider with auto_topup enabled in provider_settings:
|
||||
1. Checks the upstream balance via get_balance()
|
||||
2. If balance < topup_threshold, creates a cashu token from the configured mint
|
||||
3. Sends the token to the upstream provider via topup()
|
||||
"""
|
||||
# Wait for initial startup to complete
|
||||
await asyncio.sleep(30)
|
||||
logger.info("Auto top-up worker started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await _run_auto_topup_cycle()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Auto top-up cycle failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
await asyncio.sleep(AUTO_TOPUP_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def _run_auto_topup_cycle() -> None:
|
||||
"""Single cycle: check all eligible providers and top up if needed."""
|
||||
async with create_session() as session:
|
||||
query = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.provider_type == "routstr",
|
||||
UpstreamProviderRow.enabled == True, # noqa: E712
|
||||
)
|
||||
result = await session.exec(query)
|
||||
providers = result.all()
|
||||
|
||||
for row in providers:
|
||||
try:
|
||||
await _check_and_topup(row)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Auto top-up failed for provider",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"base_url": row.base_url,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
||||
"""Check a single provider's balance and top up if below threshold."""
|
||||
# Parse provider settings
|
||||
settings: dict = {}
|
||||
if row.provider_settings:
|
||||
try:
|
||||
settings = json.loads(row.provider_settings)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
|
||||
if not settings.get("auto_topup"):
|
||||
return
|
||||
|
||||
threshold = settings.get("topup_threshold")
|
||||
amount = settings.get("topup_amount_limit")
|
||||
mint_url = settings.get("topup_mint_url")
|
||||
|
||||
if not threshold or not amount or not mint_url:
|
||||
logger.warning(
|
||||
"Auto top-up enabled but missing configuration",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"has_threshold": bool(threshold),
|
||||
"has_amount": bool(amount),
|
||||
"has_mint": bool(mint_url),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if not row.api_key:
|
||||
return
|
||||
|
||||
# Instantiate provider and check balance
|
||||
provider = RoutstrUpstreamProvider.from_db_row(row)
|
||||
balance = await provider.get_balance()
|
||||
|
||||
if balance is None:
|
||||
logger.warning(
|
||||
"Could not fetch balance for auto top-up",
|
||||
extra={"provider_id": row.id, "base_url": row.base_url},
|
||||
)
|
||||
return
|
||||
|
||||
if balance >= threshold * 1000:
|
||||
return
|
||||
|
||||
# Balance is below threshold - create token and top up
|
||||
logger.info(
|
||||
"Auto top-up triggered",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"balance": balance,
|
||||
"threshold": threshold,
|
||||
"topup_amount": amount,
|
||||
"mint_url": mint_url,
|
||||
},
|
||||
)
|
||||
|
||||
print(amount, mint_url)
|
||||
try:
|
||||
token = await send_token(amount, "sat", mint_url)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create cashu token for auto top-up",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"amount": amount,
|
||||
"mint_url": mint_url,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
result = await provider.topup(token)
|
||||
|
||||
if "error" in result:
|
||||
logger.error(
|
||||
"Auto top-up upstream call failed",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"error": result["error"],
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Auto top-up completed successfully",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"amount": amount,
|
||||
"new_balance_approx": balance + amount,
|
||||
},
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
|
||||
class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
@@ -58,19 +59,52 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def prepare_headers(self, request_headers: dict) -> dict:
|
||||
"""Prepare headers for Azure OpenAI, adding api-key."""
|
||||
headers = super().prepare_headers(request_headers)
|
||||
if self.api_key:
|
||||
headers["api-key"] = self.api_key
|
||||
headers.pop("Authorization", None)
|
||||
headers.pop("authorization", None)
|
||||
return headers
|
||||
|
||||
def prepare_params(
|
||||
self, path: str, query_params: Mapping[str, str] | None
|
||||
) -> Mapping[str, str]:
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version.
|
||||
|
||||
Args:
|
||||
path: Request path
|
||||
query_params: Original query parameters from the client
|
||||
|
||||
Returns:
|
||||
Query parameters dict with Azure API version added for chat completions
|
||||
"""
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version."""
|
||||
params = dict(query_params or {})
|
||||
if path.endswith("chat/completions"):
|
||||
params["api-version"] = self.api_version
|
||||
version = (self.api_version or "").replace("\ufeff", "").strip()
|
||||
if not version or version.lower() == "v1":
|
||||
version = "2024-02-15-preview"
|
||||
params["api-version"] = version
|
||||
return params
|
||||
|
||||
def normalize_request_path(
|
||||
self, path: str, model_obj: "Model | None" = None
|
||||
) -> str:
|
||||
"""Build Azure deployment-specific request path."""
|
||||
clean_path = super().normalize_request_path(path, model_obj).lstrip("/")
|
||||
if model_obj is None:
|
||||
return clean_path
|
||||
|
||||
deployment_id = getattr(
|
||||
model_obj, "canonical_slug", None
|
||||
) or self.transform_model_name(model_obj.id)
|
||||
deployment_id = deployment_id.split("/")[-1]
|
||||
return f"openai/deployments/{deployment_id}/{clean_path}"
|
||||
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: "Model | None" = None
|
||||
) -> str:
|
||||
"""Use endpoint root, stripping accidental /openai/v1 suffix if present."""
|
||||
base_url = self.base_url.rstrip("/")
|
||||
marker = "/openai/v1"
|
||||
if marker in base_url:
|
||||
base_url = base_url.split(marker, 1)[0].rstrip("/")
|
||||
return base_url
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Extract deployment name from model ID."""
|
||||
if "/" in model_id:
|
||||
return model_id.split("/")[-1]
|
||||
return model_id
|
||||
|
||||
@@ -13,7 +13,7 @@ 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, UpstreamProviderRow, create_session
|
||||
from ..core.exceptions import UpstreamError
|
||||
@@ -197,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:
|
||||
@@ -523,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", {}
|
||||
@@ -534,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:
|
||||
@@ -624,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",
|
||||
@@ -801,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
|
||||
@@ -809,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(
|
||||
@@ -821,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:
|
||||
@@ -905,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",
|
||||
@@ -1035,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)
|
||||
|
||||
@@ -1213,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):
|
||||
@@ -1244,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(
|
||||
@@ -1279,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)
|
||||
|
||||
@@ -1435,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):
|
||||
@@ -1466,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(
|
||||
@@ -1491,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",
|
||||
@@ -3027,7 +3093,7 @@ class BaseUpstreamProvider:
|
||||
UpstreamProviderRow.api_key == self.api_key
|
||||
)
|
||||
result = await session.exec(stmt)
|
||||
|
||||
|
||||
# .first() returns the object or None if not found
|
||||
provider = result.first()
|
||||
if not provider or not provider.id:
|
||||
@@ -3049,7 +3115,6 @@ class BaseUpstreamProvider:
|
||||
models.append(found_db_model)
|
||||
|
||||
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
||||
print([mode.id for mode in models_with_fees])
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
|
||||
@@ -259,7 +259,15 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key, openai_format_response, session, max_cost_for_model
|
||||
)
|
||||
await session.refresh(key)
|
||||
remaining_balance_msats = key.balance
|
||||
openai_format_response["cost"] = cost_data
|
||||
openai_format_response["cost"]["sats_cost"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
openai_format_response["cost"]["remaining_balance_msats"] = (
|
||||
remaining_balance_msats
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Gemini non-streaming payment completed",
|
||||
|
||||
@@ -205,6 +205,9 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if provider:
|
||||
# Keep provider DB id on runtime instance so model mapping can
|
||||
# bind DB overrides to the correct upstream.
|
||||
setattr(provider, "db_id", provider_row.id)
|
||||
await provider.refresh_models_cache()
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
|
||||
@@ -3,13 +3,11 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
@@ -67,38 +65,11 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Strip 'ollama/' prefix for Ollama API compatibility."""
|
||||
return model_id.removeprefix("ollama/")
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Override to use OpenAI-compatible endpoint for proxy requests."""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
original_base_url = self.base_url
|
||||
self.base_url = f"{self.base_url}/v1"
|
||||
|
||||
try:
|
||||
result = await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
self.base_url = original_base_url
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: Model | None = None
|
||||
) -> str:
|
||||
"""Route proxy traffic through Ollama's OpenAI-compatible /v1 endpoint."""
|
||||
return f"{self.base_url.rstrip('/')}/v1"
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from Ollama API using /api/tags endpoint."""
|
||||
|
||||
@@ -283,7 +283,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
print(f"Payload: {payload}", "sending to", url)
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
invoice_data = response.json()
|
||||
|
||||
169
routstr/upstream/routstr.py
Normal file
169
routstr/upstream/routstr.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core import get_logger
|
||||
from ..payment.models import Model
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for communicating with another Routstr instance."""
|
||||
|
||||
provider_type = "routstr"
|
||||
default_base_url = None
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider_fee: float = 1.01,
|
||||
provider_settings: dict | None = None,
|
||||
):
|
||||
"""Initialize Routstr provider.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the upstream Routstr instance
|
||||
api_key: API key for the upstream Routstr instance
|
||||
provider_fee: Provider fee multiplier
|
||||
provider_settings: Provider-specific settings (auto-topup, etc.)
|
||||
"""
|
||||
# Ensure base_url doesn't end with /v1 as BaseUpstreamProvider appends it if needed
|
||||
# but Routstr paths are usually absolute from base.
|
||||
super().__init__(
|
||||
base_url=base_url.rstrip("/"),
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
self.settings = provider_settings or {}
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "RoutstrUpstreamProvider":
|
||||
import json
|
||||
|
||||
settings = {}
|
||||
if provider_row.provider_settings:
|
||||
try:
|
||||
settings = json.loads(provider_row.provider_settings)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
provider_settings=settings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Routstr Node",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": False,
|
||||
"can_topup": True,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Fetch balance from the upstream Routstr node.
|
||||
|
||||
Returns:
|
||||
Balance in satoshis, or None if failed
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/info"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Routstr balance info usually contains 'balance' in msats or sats
|
||||
# Check for msats and convert to sats
|
||||
if "balance_msats" in data:
|
||||
return float(data["balance_msats"]) / 1000.0
|
||||
return float(data.get("balance", 0))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to fetch balance from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
async def topup(self, cashu_token: str) -> dict[str, Any]:
|
||||
"""Top up balance on the upstream Routstr node.
|
||||
|
||||
Args:
|
||||
cashu_token: Cashu token to deposit
|
||||
|
||||
Returns:
|
||||
Dict containing top-up result
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/topup"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
payload = {"cashu_token": cashu_token}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
url, headers=headers, json=payload, timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to topup upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return {"error": str(e)}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from the upstream Routstr node."""
|
||||
url = f"{self.base_url}/v1/models"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers={}, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models = data.get("data", [])
|
||||
return [Model(**m) for m in models]
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to fetch models from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return []
|
||||
|
||||
async def refund_balance(self) -> dict[str, Any]:
|
||||
"""Request a refund from the upstream Routstr node.
|
||||
|
||||
Returns:
|
||||
Dict containing refund result and token
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/refund"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(url, headers=headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to request refund from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return {"error": str(e)}
|
||||
80
tests/integration/test_admin_provider_balance.py
Normal file
80
tests/integration/test_admin_provider_balance.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
async def _create_routstr_provider() -> UpstreamProviderRow:
|
||||
return UpstreamProviderRow(
|
||||
provider_type="routstr",
|
||||
base_url="https://upstream.example",
|
||||
api_key="",
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
token = "test-admin-token"
|
||||
admin_sessions[token] = int(
|
||||
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_provider_balance_timeout_returns_504(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
provider = await _create_routstr_provider()
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
|
||||
timeout_error = httpx.ConnectTimeout("Connect timeout", request=request)
|
||||
|
||||
with patch(
|
||||
"httpx.AsyncHTTPTransport.handle_async_request",
|
||||
new=AsyncMock(side_effect=timeout_error),
|
||||
):
|
||||
response = await integration_client.get(
|
||||
f"/admin/api/upstream-providers/{provider.id}/balance",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 504
|
||||
assert response.json()["detail"] == "Timed out contacting upstream Routstr provider"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_provider_balance_request_error_returns_502(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
provider = await _create_routstr_provider()
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
|
||||
request_error = httpx.ConnectError("Connection failed", request=request)
|
||||
|
||||
with patch(
|
||||
"httpx.AsyncHTTPTransport.handle_async_request",
|
||||
new=AsyncMock(side_effect=request_error),
|
||||
):
|
||||
response = await integration_client.get(
|
||||
f"/admin/api/upstream-providers/{provider.id}/balance",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.json()["detail"] == "Failed to contact upstream Routstr provider"
|
||||
@@ -3,12 +3,16 @@ Integration tests for provider management functionality.
|
||||
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
||||
"""
|
||||
|
||||
import time
|
||||
from types import TracebackType
|
||||
from typing import Any, Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import UpstreamProviderRow
|
||||
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
||||
|
||||
from .utils import ResponseValidator
|
||||
@@ -678,3 +682,88 @@ async def test_no_database_changes_during_provider_operations(
|
||||
assert final_diff["api_keys"]["added"] == []
|
||||
assert final_diff["api_keys"]["modified"] == []
|
||||
assert final_diff["api_keys"]["removed"] == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_topup_retries_transient_upstream_failure(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
admin_token = "test-admin-token"
|
||||
admin_sessions[admin_token] = int(time.time()) + 3600
|
||||
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
provider_type="routstr",
|
||||
base_url="https://node.example",
|
||||
api_key="sk-upstream-test",
|
||||
enabled=True,
|
||||
provider_fee=1.01,
|
||||
)
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
class MockResponse:
|
||||
def __init__(self, status_code: int, data: dict[str, Any] | None = None):
|
||||
self.status_code = status_code
|
||||
self._data = data or {}
|
||||
self.text = str(self._data)
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._data
|
||||
|
||||
class MockAsyncClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def __aenter__(self) -> "MockAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
async def post(
|
||||
self, url: str, json: dict[str, Any], headers: dict[str, str]
|
||||
) -> MockResponse:
|
||||
self.calls += 1
|
||||
assert url == "https://node.example/v1/balance/lightning/invoice"
|
||||
assert json["amount_sats"] == 10
|
||||
assert json["purpose"] == "topup"
|
||||
assert json["api_key"] == "sk-upstream-test"
|
||||
assert headers["Authorization"] == "Bearer sk-upstream-test"
|
||||
|
||||
if self.calls == 1:
|
||||
return MockResponse(500, {"detail": "warmup failure"})
|
||||
|
||||
return MockResponse(
|
||||
200,
|
||||
{
|
||||
"bolt11": "lnbc1testinvoice",
|
||||
"invoice_id": "invoice-123",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = MockAsyncClient()
|
||||
|
||||
try:
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
response = await integration_client.post(
|
||||
f"/admin/api/upstream-providers/{provider.id}/topup",
|
||||
json={"amount": 10},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
|
||||
assert data["topup_data"]["invoice_id"] == "invoice-123"
|
||||
assert mock_client.calls == 2
|
||||
finally:
|
||||
admin_sessions.pop(admin_token, None)
|
||||
|
||||
@@ -171,10 +171,11 @@ async def test_proxy_get_unauthorized_access(integration_client: AsyncClient) ->
|
||||
assert response.status_code == 200 # GET requests are allowed
|
||||
|
||||
# Test 2: POST requests without auth should return 401
|
||||
# Note: Model validation happens before auth, so missing model returns 400
|
||||
response = await integration_client.post(
|
||||
"/v1/chat/completions", json={"test": "data"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.status_code in [400, 401] # Accept both for now
|
||||
|
||||
# Test 3: POST with invalid API key
|
||||
# Note: After refactor, model validation may happen before auth validation
|
||||
@@ -550,9 +551,6 @@ async def test_proxy_get_concurrent_requests(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_get_response_format_preservation(
|
||||
|
||||
@@ -133,13 +133,16 @@ async def test_reserved_balance_with_successful_requests(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_reserved_balance_for_revert(
|
||||
async def test_revert_with_zero_reserved_balance_is_noop(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
|
||||
"""Test that revert_pay_for_request is a no-op when reserved_balance is 0.
|
||||
|
||||
Previously this would drive reserved_balance negative. With the floor guard,
|
||||
it should return False and leave reserved_balance at 0.
|
||||
"""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
# Create key with zero reserved balance
|
||||
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
@@ -149,17 +152,211 @@ async def test_insufficient_reserved_balance_for_revert(
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Try to revert more than available
|
||||
# Note: Current implementation allows reserved_balance to go negative
|
||||
await revert_pay_for_request(test_key, integration_session, 100)
|
||||
# Try to revert more than available — should be a no-op
|
||||
result = await revert_pay_for_request(test_key, integration_session, 100)
|
||||
|
||||
# Refresh to get updated values
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
# Current implementation allows negative reserved balance
|
||||
assert test_key.reserved_balance == -100, (
|
||||
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
|
||||
assert result is False, "Revert should return False when reservation already released"
|
||||
assert test_key.reserved_balance == 0, (
|
||||
f"Reserved balance should remain 0, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == -1, (
|
||||
f"Expected total_requests to be -1, got: {test_key.total_requests}"
|
||||
assert test_key.total_requests == 0, (
|
||||
f"Total requests should remain 0, got: {test_key.total_requests}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revert_with_sufficient_reserved_balance_succeeds(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that revert_pay_for_request works correctly when there is enough reserved balance."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
unique_key = f"test_revert_ok_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=5000,
|
||||
reserved_balance=500,
|
||||
total_requests=3,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
result = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
assert result is True, "Revert should return True on success"
|
||||
assert test_key.reserved_balance == 0, (
|
||||
f"Reserved balance should be 0, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == 2, (
|
||||
f"Total requests should be 2, got: {test_key.total_requests}"
|
||||
)
|
||||
assert test_key.balance == 5000, "Balance should not change on revert"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revert_partial_reserved_balance_is_noop(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that reverting more than the current reserved_balance is a no-op."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
unique_key = f"test_revert_partial_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=5000,
|
||||
reserved_balance=50,
|
||||
total_requests=1,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Try to revert 500 when only 50 is reserved — should be no-op
|
||||
result = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
assert result is False, "Revert should fail when cost > reserved_balance"
|
||||
assert test_key.reserved_balance == 50, (
|
||||
f"Reserved balance should stay at 50, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == 1, (
|
||||
f"Total requests should stay at 1, got: {test_key.total_requests}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_revert_prevented(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that calling revert twice doesn't drive reserved_balance negative.
|
||||
|
||||
This simulates the double-revert scenario where both upstream/base.py
|
||||
and proxy.py attempt to revert the same reservation.
|
||||
"""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
unique_key = f"test_double_revert_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=10000,
|
||||
reserved_balance=500,
|
||||
total_requests=5,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# First revert — should succeed
|
||||
result1 = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
assert result1 is True
|
||||
assert test_key.reserved_balance == 0
|
||||
assert test_key.total_requests == 4
|
||||
|
||||
# Second revert of the same amount — should be no-op
|
||||
result2 = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
assert result2 is False, "Second revert should be a no-op"
|
||||
assert test_key.reserved_balance == 0, (
|
||||
f"Reserved balance should stay 0, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == 4, (
|
||||
f"Total requests should stay 4, got: {test_key.total_requests}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequential_reverts_never_go_negative(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that multiple reverts don't cause negative reserved_balance.
|
||||
|
||||
Simulates the double-revert scenario where multiple code paths
|
||||
attempt to revert the same reservation.
|
||||
"""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
unique_key = f"test_multi_revert_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=10000,
|
||||
reserved_balance=500,
|
||||
total_requests=5,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Run 5 sequential reverts for the same 500 reservation
|
||||
results = []
|
||||
for _ in range(5):
|
||||
r = await revert_pay_for_request(test_key, integration_session, 500)
|
||||
results.append(r)
|
||||
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
# Exactly one should succeed, rest should be no-ops
|
||||
success_count = sum(1 for r in results if r is True)
|
||||
assert success_count == 1, (
|
||||
f"Exactly one revert should succeed, got {success_count} successes"
|
||||
)
|
||||
assert test_key.reserved_balance == 0, (
|
||||
f"Reserved balance should be 0, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {test_key.reserved_balance}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_key_revert_floor_guard(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that child key reserved_balance also has floor guard on revert."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
parent_key_hash = f"test_parent_{uuid.uuid4().hex[:8]}"
|
||||
child_key_hash = f"test_child_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
parent_key = ApiKey(
|
||||
hashed_key=parent_key_hash,
|
||||
balance=10000,
|
||||
reserved_balance=500,
|
||||
total_requests=3,
|
||||
)
|
||||
child_key = ApiKey(
|
||||
hashed_key=child_key_hash,
|
||||
balance=0,
|
||||
reserved_balance=500,
|
||||
total_requests=3,
|
||||
parent_key_hash=parent_key_hash,
|
||||
)
|
||||
integration_session.add(parent_key)
|
||||
integration_session.add(child_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# First revert succeeds
|
||||
result1 = await revert_pay_for_request(child_key, integration_session, 500)
|
||||
await integration_session.refresh(parent_key)
|
||||
await integration_session.refresh(child_key)
|
||||
|
||||
assert result1 is True
|
||||
assert parent_key.reserved_balance == 0
|
||||
assert child_key.reserved_balance == 0
|
||||
|
||||
# Second revert is a no-op for both parent and child
|
||||
result2 = await revert_pay_for_request(child_key, integration_session, 500)
|
||||
await integration_session.refresh(parent_key)
|
||||
await integration_session.refresh(child_key)
|
||||
|
||||
assert result2 is False
|
||||
assert parent_key.reserved_balance == 0, (
|
||||
f"Parent reserved_balance should stay 0, got: {parent_key.reserved_balance}"
|
||||
)
|
||||
assert child_key.reserved_balance == 0, (
|
||||
f"Child reserved_balance should stay 0, got: {child_key.reserved_balance}"
|
||||
)
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""Tests for the model prioritization algorithm."""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
# Set required env vars before importing
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.algorithm import ( # noqa: E402
|
||||
calculate_model_cost_score,
|
||||
create_model_mappings,
|
||||
get_provider_penalty,
|
||||
)
|
||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||
@@ -45,11 +49,21 @@ def create_test_model(
|
||||
)
|
||||
|
||||
|
||||
def create_test_provider(name: str, base_url: str = "http://test.com") -> Mock:
|
||||
def create_test_provider(
|
||||
name: str,
|
||||
base_url: str = "http://test.com",
|
||||
*,
|
||||
db_id: int | None = None,
|
||||
models: list[Model] | None = None,
|
||||
upstream_name: str | None = None,
|
||||
) -> Mock:
|
||||
"""Helper to create a test provider mock."""
|
||||
provider = Mock()
|
||||
provider.provider_type = name
|
||||
provider.base_url = base_url
|
||||
provider.db_id = db_id
|
||||
provider.upstream_name = upstream_name or name
|
||||
provider.get_cached_models.return_value = models or []
|
||||
return provider
|
||||
|
||||
|
||||
@@ -99,3 +113,80 @@ def test_get_provider_penalty_openrouter() -> None:
|
||||
provider = create_test_provider("openrouter", "https://openrouter.ai/api/v1")
|
||||
penalty = get_provider_penalty(provider)
|
||||
assert penalty == 1.001
|
||||
|
||||
|
||||
def test_create_model_mappings_includes_db_override_for_missing_cached_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Model overrides should still map when provider discovery misses the model."""
|
||||
provider = create_test_provider(
|
||||
"azure",
|
||||
"https://example.openai.azure.com/openai/v1",
|
||||
db_id=7,
|
||||
models=[],
|
||||
)
|
||||
override_model = create_test_model("azure/gpt-4o")
|
||||
override_model.canonical_slug = "azure-deployment"
|
||||
|
||||
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
|
||||
return override_model
|
||||
|
||||
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
|
||||
|
||||
override_row = SimpleNamespace(id="azure/gpt-4o", upstream_provider_id=7, enabled=True)
|
||||
|
||||
model_instances, provider_map, unique_models = create_model_mappings(
|
||||
upstreams=[provider],
|
||||
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
|
||||
disabled_model_ids=set(),
|
||||
)
|
||||
|
||||
assert "azure/gpt-4o" in model_instances
|
||||
assert provider_map["azure/gpt-4o"] == [provider]
|
||||
assert "gpt-4o" in unique_models
|
||||
|
||||
|
||||
def test_create_model_mappings_dedupes_with_provider_identity_not_provider_type(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Different provider instances of same type should both survive dedupe."""
|
||||
provider_a_model = create_test_model(
|
||||
"azure/gpt-4o", prompt_price=0.01, completion_price=0.01
|
||||
)
|
||||
provider_a = create_test_provider(
|
||||
"azure",
|
||||
"https://a.openai.azure.com/openai/v1",
|
||||
db_id=1,
|
||||
models=[provider_a_model],
|
||||
upstream_name="azure-a",
|
||||
)
|
||||
provider_b = create_test_provider(
|
||||
"azure",
|
||||
"https://b.openai.azure.com/openai/v1",
|
||||
db_id=2,
|
||||
models=[],
|
||||
upstream_name="azure-b",
|
||||
)
|
||||
|
||||
override_model = create_test_model(
|
||||
"azure/gpt-4o", prompt_price=0.001, completion_price=0.001
|
||||
)
|
||||
override_model.canonical_slug = "azure-b-deployment"
|
||||
|
||||
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
|
||||
return override_model
|
||||
|
||||
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
|
||||
|
||||
override_row = SimpleNamespace(id="azure/gpt-4o", upstream_provider_id=2, enabled=True)
|
||||
|
||||
_, provider_map, _ = create_model_mappings(
|
||||
upstreams=[provider_a, provider_b],
|
||||
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
|
||||
disabled_model_ids=set(),
|
||||
)
|
||||
|
||||
providers_for_alias = provider_map["azure/gpt-4o"]
|
||||
assert provider_a in providers_for_alias
|
||||
assert provider_b in providers_for_alias
|
||||
assert len(providers_for_alias) == 2
|
||||
|
||||
82
tests/unit/test_upstream_azure.py
Normal file
82
tests/unit/test_upstream_azure.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Tests for Azure upstream provider request normalization."""
|
||||
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
from routstr.upstream.azure import AzureUpstreamProvider
|
||||
|
||||
|
||||
def create_test_model(model_id: str, canonical_slug: str | None = None) -> Model:
|
||||
return Model(
|
||||
id=model_id,
|
||||
name=model_id,
|
||||
created=0,
|
||||
description="test",
|
||||
context_length=8192,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="test",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.001,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
canonical_slug=canonical_slug,
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_headers_uses_azure_api_key_header() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
|
||||
headers = provider.prepare_headers({"Authorization": "Bearer user-token"})
|
||||
|
||||
assert headers["api-key"] == "azure-key"
|
||||
assert "Authorization" not in headers
|
||||
assert "authorization" not in headers
|
||||
|
||||
|
||||
def test_prepare_params_normalizes_azure_api_version() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com",
|
||||
api_key="azure-key",
|
||||
api_version="\ufeff v1 ",
|
||||
)
|
||||
|
||||
params = provider.prepare_params("chat/completions", {})
|
||||
|
||||
assert params["api-version"] == "2024-02-15-preview"
|
||||
|
||||
|
||||
def test_normalize_request_path_includes_deployment_id() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com/openai/v1",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
model = create_test_model("azure/gpt-4o", canonical_slug="deploy-gpt4o")
|
||||
|
||||
path = provider.normalize_request_path("v1/chat/completions", model)
|
||||
|
||||
assert path == "openai/deployments/deploy-gpt4o/chat/completions"
|
||||
|
||||
|
||||
def test_get_request_base_url_strips_openai_v1_suffix() -> None:
|
||||
provider = AzureUpstreamProvider(
|
||||
base_url="https://example.openai.azure.com/openai/v1",
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
)
|
||||
model = create_test_model("azure/gpt-4o")
|
||||
|
||||
base_url = provider.get_request_base_url("chat/completions", model)
|
||||
|
||||
assert base_url == "https://example.openai.azure.com"
|
||||
76
tests/unit/test_upstream_routstr.py
Normal file
76
tests/unit/test_upstream_routstr.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from types import TracebackType
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.routstr import RoutstrUpstreamProvider
|
||||
|
||||
|
||||
class DummyAsyncClient:
|
||||
def __init__(self, response: Mock | None = None, error: Exception | None = None):
|
||||
self.response = response
|
||||
self.error = error
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
async def __aenter__(self) -> "DummyAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def get(
|
||||
self, url: str, headers: dict[str, str], timeout: float
|
||||
) -> Mock:
|
||||
self.calls.append({"url": url, "headers": headers, "timeout": timeout})
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
assert self.response is not None
|
||||
return self.response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_omits_auth_header_when_api_key_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.json.return_value = {"balance_msats": 42000}
|
||||
response.raise_for_status.return_value = None
|
||||
|
||||
client = DummyAsyncClient(response=response)
|
||||
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
|
||||
|
||||
provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="")
|
||||
|
||||
balance = await provider.get_balance()
|
||||
|
||||
assert balance == 42.0
|
||||
assert client.calls == [
|
||||
{
|
||||
"url": "https://node.example/v1/balance/info",
|
||||
"headers": {},
|
||||
"timeout": 10.0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_returns_none_on_connect_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out"))
|
||||
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
|
||||
|
||||
provider = RoutstrUpstreamProvider(
|
||||
base_url="https://node.example",
|
||||
api_key="secret",
|
||||
)
|
||||
|
||||
balance = await provider.get_balance()
|
||||
|
||||
assert balance is None
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next",
|
||||
"next/core-web-vitals",
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"plugins": ["react", "@typescript-eslint"]
|
||||
}
|
||||
1
ui/.gitignore
vendored
1
ui/.gitignore
vendored
@@ -42,3 +42,4 @@ next-env.d.ts
|
||||
|
||||
# favicon conflicts
|
||||
/app/favicon.ico
|
||||
.pnpm-store/
|
||||
|
||||
5
ui/.prettierignore
Normal file
5
ui/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
pnpm-lock.yaml
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
next-env.d.ts
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
import { useCurrencyStore } from '@/lib/stores/currency';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
|
||||
import { TemporaryBalances } from '@/components/temporary-balances';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
|
||||
export default function BalancesPage() {
|
||||
const { displayUnit } = useCurrencyStore();
|
||||
@@ -22,39 +21,26 @@ export default function BalancesPage() {
|
||||
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor and manage wallet balances
|
||||
</p>
|
||||
</div>
|
||||
{/* Global currency toggle is now in SiteHeader */}
|
||||
</div>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='Balances'
|
||||
description='Monitor and manage wallet balances across cashu mints and temporary stores.'
|
||||
/>
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<div className='col-span-full'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-6'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,133 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:is(.dark *, .red *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--font-geist-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
|
||||
Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
.red {
|
||||
--background: oklch(0.14 0.03 24);
|
||||
--foreground: oklch(0.9 0.05 28);
|
||||
--card: oklch(0.17 0.04 24);
|
||||
--card-foreground: oklch(0.9 0.05 28);
|
||||
--popover: oklch(0.17 0.04 24);
|
||||
--popover-foreground: oklch(0.9 0.05 28);
|
||||
--primary: oklch(0.78 0.14 25);
|
||||
--primary-foreground: oklch(0.14 0.03 24);
|
||||
--secondary: oklch(0.22 0.05 24);
|
||||
--secondary-foreground: oklch(0.9 0.05 28);
|
||||
--muted: oklch(0.22 0.05 24);
|
||||
--muted-foreground: oklch(0.72 0.04 26);
|
||||
--accent: oklch(0.25 0.08 25);
|
||||
--accent-foreground: oklch(0.92 0.05 28);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(0.72 0.11 25 / 24%);
|
||||
--input: oklch(0.75 0.12 25 / 28%);
|
||||
--ring: oklch(0.62 0.12 24);
|
||||
}
|
||||
|
||||
html.red {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-border: var(--border);
|
||||
--color-card: var(--card);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -21,194 +141,34 @@
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--font-serif: Georgia, serif;
|
||||
--radius: 0.5rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--spacing: var(--spacing);
|
||||
--letter-spacing: var(--letter-spacing);
|
||||
--shadow-offset-y: var(--shadow-offset-y);
|
||||
--shadow-offset-x: var(--shadow-offset-x);
|
||||
--shadow-spread: var(--shadow-spread);
|
||||
--shadow-blur: var(--shadow-blur);
|
||||
--shadow-opacity: var(--shadow-opacity);
|
||||
--color-shadow-color: var(--shadow-color);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.99 0 0);
|
||||
--foreground: oklch(0 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0 0 0);
|
||||
--popover: oklch(0.99 0 0);
|
||||
--popover-foreground: oklch(0 0 0);
|
||||
--primary: oklch(0 0 0);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.94 0 0);
|
||||
--secondary-foreground: oklch(0 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.44 0 0);
|
||||
--accent: oklch(0.94 0 0);
|
||||
--accent-foreground: oklch(0 0 0);
|
||||
--destructive: oklch(0.63 0.19 23.03);
|
||||
--border: oklch(0.92 0 0);
|
||||
--input: oklch(0.94 0 0);
|
||||
--ring: oklch(0 0 0);
|
||||
--chart-1: oklch(0.81 0.17 75.35);
|
||||
--chart-2: oklch(0.55 0.22 264.53);
|
||||
--chart-3: oklch(0.72 0 0);
|
||||
--chart-4: oklch(0.92 0 0);
|
||||
--chart-5: oklch(0.56 0 0);
|
||||
--sidebar: oklch(0.99 0 0);
|
||||
--sidebar-foreground: oklch(0 0 0);
|
||||
--sidebar-primary: oklch(0 0 0);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.94 0 0);
|
||||
--sidebar-accent-foreground: oklch(0 0 0);
|
||||
--sidebar-border: oklch(0.94 0 0);
|
||||
--sidebar-ring: oklch(0 0 0);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-blur: 2px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 1px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
||||
--tracking-normal: 0em;
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(1 0 0);
|
||||
--card: oklch(0.14 0 0);
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0.18 0 0);
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
--primary: oklch(1 0 0);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.25 0 0);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.23 0 0);
|
||||
--muted-foreground: oklch(0.72 0 0);
|
||||
--accent: oklch(0.32 0 0);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.69 0.2 23.91);
|
||||
--border: oklch(0.26 0 0);
|
||||
--input: oklch(0.32 0 0);
|
||||
--ring: oklch(0.72 0 0);
|
||||
--chart-1: oklch(0.81 0.17 75.35);
|
||||
--chart-2: oklch(0.58 0.21 260.84);
|
||||
--chart-3: oklch(0.56 0 0);
|
||||
--chart-4: oklch(0.44 0 0);
|
||||
--chart-5: oklch(0.92 0 0);
|
||||
--sidebar: oklch(0.18 0 0);
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-primary: oklch(1 0 0);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.32 0 0);
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(0.32 0 0);
|
||||
--sidebar-ring: oklch(0.72 0 0);
|
||||
--destructive-foreground: oklch(0 0 0);
|
||||
--radius: 0.5rem;
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-blur: 2px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 1px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl:
|
||||
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
||||
html,
|
||||
body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
button,
|
||||
[type='button'],
|
||||
[type='submit'],
|
||||
[type='reset'],
|
||||
[role='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Routstr',
|
||||
description: 'Routstr model management',
|
||||
@@ -34,7 +21,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<SuppressHydrationWarning>
|
||||
<Providers>{children}</Providers>
|
||||
|
||||
@@ -5,16 +5,10 @@ import type { ChangeEvent, FormEvent, ReactElement } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { adminLogin } from '@/lib/api/services/auth';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { toast } from 'sonner';
|
||||
import { AuthPageShell } from '@/components/auth-page-shell';
|
||||
|
||||
export default function AdminLoginPage(): ReactElement {
|
||||
const router = useRouter();
|
||||
@@ -78,51 +72,42 @@ export default function AdminLoginPage(): ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
|
||||
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='text-center text-2xl font-bold'>
|
||||
Admin Login
|
||||
</CardTitle>
|
||||
<CardDescription className='text-center'>
|
||||
Enter your admin password to access the dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{allowCustomBaseUrl && (
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='API URL (https://api.example.com)'
|
||||
value={baseUrl}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setBaseUrl(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Admin Password'
|
||||
value={password}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<AuthPageShell
|
||||
title='Admin Login'
|
||||
description='Enter your admin password to access the dashboard.'
|
||||
>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{allowCustomBaseUrl && (
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='API URL (https://api.example.com)'
|
||||
value={baseUrl}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setBaseUrl(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Admin Password'
|
||||
value={password}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,18 +10,8 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface LogEntry {
|
||||
asctime: string;
|
||||
name: string;
|
||||
levelname: string;
|
||||
message: string;
|
||||
pathname: string;
|
||||
lineno: number;
|
||||
version: string;
|
||||
request_id: string;
|
||||
[key: string]: string | number | object | undefined;
|
||||
}
|
||||
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogDetailsDialogProps {
|
||||
log: LogEntry | null;
|
||||
@@ -29,24 +19,6 @@ interface LogDetailsDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogDetailsDialog({
|
||||
log,
|
||||
isOpen,
|
||||
@@ -79,10 +51,13 @@ export function LogDetailsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
|
||||
<DialogContent className='max-h-[92svh] w-full max-w-none overflow-hidden md:max-w-4xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className={getLevelColor(log.levelname)}>
|
||||
<Badge
|
||||
variant={getLogLevelBadgeVariant(log.levelname)}
|
||||
className='uppercase'
|
||||
>
|
||||
{log.levelname}
|
||||
</Badge>
|
||||
<span>Log Entry Details</span>
|
||||
@@ -92,7 +67,7 @@ export function LogDetailsDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
|
||||
<ScrollArea className='h-[70svh] w-full overflow-x-auto sm:h-[75vh]'>
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
interface LogEntry {
|
||||
asctime: string;
|
||||
name: string;
|
||||
levelname: string;
|
||||
message: string;
|
||||
pathname: string;
|
||||
lineno: number;
|
||||
version: string;
|
||||
request_id: string;
|
||||
[key: string]: string | number | object | undefined;
|
||||
}
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogEntryCardProps {
|
||||
entry: LogEntry;
|
||||
onClick: (entry: LogEntry) => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
|
||||
const extraFields = Object.keys(entry).filter(
|
||||
(key) =>
|
||||
@@ -50,73 +22,58 @@ export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
|
||||
'request_id',
|
||||
].includes(key)
|
||||
);
|
||||
const hasRequestId =
|
||||
Boolean(entry.request_id) && entry.request_id !== 'no-request-id';
|
||||
const shortPath = entry.pathname.split('/').pop() || entry.pathname;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
|
||||
className='bg-card hover:bg-accent/35 group mb-2 cursor-pointer rounded-lg border p-2.5 transition-colors duration-150 sm:p-3'
|
||||
onClick={() => onClick(entry)}
|
||||
>
|
||||
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline' className={getLevelColor(entry.levelname)}>
|
||||
{entry.levelname}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
|
||||
{entry.asctime}
|
||||
</span>
|
||||
<Badge variant='secondary' className='truncate text-xs'>
|
||||
{entry.name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<div className='text-muted-foreground truncate text-xs'>
|
||||
{entry.pathname}:{entry.lineno}
|
||||
</div>
|
||||
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
|
||||
{entry.message}
|
||||
</div>
|
||||
|
||||
{entry.request_id && entry.request_id !== 'no-request-id' && (
|
||||
<div className='mb-2 min-w-0'>
|
||||
<div className='inline-block max-w-full'>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
|
||||
Request ID: {entry.request_id}
|
||||
</span>
|
||||
<div className='flex min-w-0 items-start justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1 space-y-1.5'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
|
||||
<Badge
|
||||
variant={getLogLevelBadgeVariant(entry.levelname)}
|
||||
className='h-5 px-1.5 text-[10px] uppercase'
|
||||
>
|
||||
{entry.levelname}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground text-[11px]'>
|
||||
{entry.asctime}
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-5 max-w-[11rem] truncate px-1.5 text-[10px]'
|
||||
>
|
||||
{entry.name}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extraFields.length > 0 && (
|
||||
<div className='mt-3 min-w-0 border-t pt-3'>
|
||||
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
|
||||
<div className='grid grid-cols-1 gap-2'>
|
||||
{extraFields.slice(0, 4).map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className='min-w-0 overflow-hidden text-xs break-words'
|
||||
>
|
||||
<span className='font-medium break-all'>{key}:</span>{' '}
|
||||
<span className='text-muted-foreground break-all'>
|
||||
{typeof entry[key] === 'object'
|
||||
? JSON.stringify(entry[key])
|
||||
: String(entry[key])}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{extraFields.length > 4 && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
...and {extraFields.length - 4} more fields
|
||||
</div>
|
||||
)}
|
||||
<p className='line-clamp-1 font-mono text-xs break-words sm:text-sm'>
|
||||
{entry.message}
|
||||
</p>
|
||||
|
||||
<div className='text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-[11px]'>
|
||||
{hasRequestId ? (
|
||||
<span className='inline-block max-w-[14rem] truncate rounded border px-1.5 py-0.5 font-mono text-[10px] sm:max-w-[20rem]'>
|
||||
{entry.request_id}
|
||||
</span>
|
||||
) : null}
|
||||
<span className='truncate'>
|
||||
{shortPath}:{entry.lineno}
|
||||
</span>
|
||||
{extraFields.length > 0 ? (
|
||||
<span>{extraFields.length} extra</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='pt-0.5'>
|
||||
<ChevronRight className='text-muted-foreground h-4 w-4 opacity-50 transition-opacity group-hover:opacity-90' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -21,20 +29,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MultiSelectCommandFilter } from './multi-select-command-filter';
|
||||
|
||||
interface LogFiltersProps {
|
||||
selectedDate: string;
|
||||
@@ -97,32 +93,12 @@ const ENDPOINT_OPTIONS = [
|
||||
'/embeddings/models',
|
||||
];
|
||||
|
||||
interface FilterBadgeProps {
|
||||
value: string;
|
||||
onRemove: (value: string) => void;
|
||||
}
|
||||
|
||||
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='flex items-center gap-1 px-1 font-normal'
|
||||
>
|
||||
{value}
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove(value);
|
||||
}}
|
||||
className='hover:bg-muted-foreground/20 rounded-full'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const STATUS_4XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
|
||||
code.startsWith('4')
|
||||
);
|
||||
const STATUS_5XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
|
||||
code.startsWith('5')
|
||||
);
|
||||
|
||||
export function LogFilters({
|
||||
selectedDate,
|
||||
@@ -151,7 +127,7 @@ export function LogFilters({
|
||||
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
|
||||
const [date, setDate] = useState<Date | undefined>(
|
||||
selectedDate && selectedDate !== 'all'
|
||||
? new Date(selectedDate + 'T00:00:00')
|
||||
? new Date(`${selectedDate}T00:00:00`)
|
||||
: undefined
|
||||
);
|
||||
|
||||
@@ -162,6 +138,7 @@ export function LogFilters({
|
||||
useEffect(() => {
|
||||
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
||||
setIsCustom(!currentIsPreset);
|
||||
|
||||
if (!currentIsPreset) {
|
||||
setCustomLimit(limit.toString());
|
||||
}
|
||||
@@ -170,88 +147,79 @@ export function LogFilters({
|
||||
useEffect(() => {
|
||||
if (selectedDate === 'all' || !selectedDate) {
|
||||
setDate(undefined);
|
||||
} else {
|
||||
const d = new Date(selectedDate + 'T00:00:00');
|
||||
setDate(isNaN(d.getTime()) ? undefined : d);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(`${selectedDate}T00:00:00`);
|
||||
setDate(Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate);
|
||||
}, [selectedDate]);
|
||||
|
||||
const handleLimitChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setIsCustom(true);
|
||||
setCustomLimit(limit.toString());
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(Number(value));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(Number(value));
|
||||
};
|
||||
|
||||
const handleCustomLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setCustomLimit(value);
|
||||
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomLimit(event.target.value);
|
||||
};
|
||||
|
||||
const handleCustomLimitApply = () => {
|
||||
const numValue = parseInt(customLimit);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
onLimitChange(numValue);
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(100);
|
||||
const numericValue = Number.parseInt(customLimit, 10);
|
||||
|
||||
if (!Number.isNaN(numericValue) && numericValue > 0) {
|
||||
onLimitChange(numericValue);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(100);
|
||||
};
|
||||
|
||||
const handleCustomLimitKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === 'Enter') {
|
||||
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleCustomLimitApply();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
setDate(selectedDate);
|
||||
if (selectedDate) {
|
||||
onDateChange(format(selectedDate, 'yyyy-MM-dd'));
|
||||
} else {
|
||||
onDateChange('all');
|
||||
}
|
||||
};
|
||||
const handleDateSelect = (nextDate: Date | undefined) => {
|
||||
setDate(nextDate);
|
||||
|
||||
const toggleSelection = (
|
||||
current: string[],
|
||||
value: string,
|
||||
onChange: (val: string[]) => void
|
||||
) => {
|
||||
if (current.includes(value)) {
|
||||
onChange(current.filter((v) => v !== value));
|
||||
} else {
|
||||
onChange([...current, value]);
|
||||
if (nextDate) {
|
||||
onDateChange(format(nextDate, 'yyyy-MM-dd'));
|
||||
return;
|
||||
}
|
||||
|
||||
onDateChange('all');
|
||||
};
|
||||
|
||||
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
|
||||
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
|
||||
const newSelection = new Set([...selectedStatusCodes]);
|
||||
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
|
||||
const rangeCodes = range === '4xx' ? STATUS_4XX_CODES : STATUS_5XX_CODES;
|
||||
const nextSelection = new Set(selectedStatusCodes);
|
||||
const allSelected = rangeCodes.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
);
|
||||
|
||||
if (allIncluded) {
|
||||
codes.forEach((c) => newSelection.delete(c));
|
||||
if (allSelected) {
|
||||
rangeCodes.forEach((code) => nextSelection.delete(code));
|
||||
} else {
|
||||
codes.forEach((c) => newSelection.add(c));
|
||||
rangeCodes.forEach((code) => nextSelection.add(code));
|
||||
}
|
||||
onStatusCodesChange(Array.from(newSelection));
|
||||
|
||||
onStatusCodesChange(Array.from(nextSelection));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Filter className='h-5 w-5' />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>
|
||||
Filter logs by date, level, request ID, text search, status code,
|
||||
method, endpoint and limit
|
||||
@@ -314,336 +282,62 @@ export function LogFilters({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>Status Codes</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selectedStatusCodes.length > 0 ? (
|
||||
selectedStatusCodes.map((code) => (
|
||||
<FilterBadge
|
||||
key={code}
|
||||
value={code}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
val,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>All codes</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-64 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add status code...'
|
||||
value={statusSearch}
|
||||
onValueChange={setStatusSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedStatusCodes.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedStatusCodes.map((code) => (
|
||||
<CommandItem
|
||||
key={`selected-${code}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
code,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{code}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{statusSearch &&
|
||||
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
|
||||
!selectedStatusCodes.includes(statusSearch) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
if (/^\d+$/.test(statusSearch)) {
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
statusSearch,
|
||||
onStatusCodesChange
|
||||
);
|
||||
setStatusSearch('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{statusSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading='Quick Filters'>
|
||||
<CommandItem
|
||||
onSelect={() => handleQuickStatusCode('4xx')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||
c.startsWith('4')
|
||||
).every((c) => selectedStatusCodes.includes(c))}
|
||||
className='mr-2'
|
||||
/>
|
||||
4xx Errors
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => handleQuickStatusCode('5xx')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||
c.startsWith('5')
|
||||
).every((c) => selectedStatusCodes.includes(c))}
|
||||
className='mr-2'
|
||||
/>
|
||||
5xx Errors
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading='Common Codes'>
|
||||
{STATUS_CODE_OPTIONS.filter(
|
||||
(code) => !selectedStatusCodes.includes(code)
|
||||
).map((code) => (
|
||||
<CommandItem
|
||||
key={code}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedStatusCodes,
|
||||
code,
|
||||
onStatusCodesChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{code}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='Status Codes'
|
||||
emptyLabel='All codes'
|
||||
selectedValues={selectedStatusCodes}
|
||||
onSelectedValuesChange={onStatusCodesChange}
|
||||
options={STATUS_CODE_OPTIONS}
|
||||
searchValue={statusSearch}
|
||||
onSearchValueChange={setStatusSearch}
|
||||
searchPlaceholder='Search or add status code...'
|
||||
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Common Codes'
|
||||
canAddCustom={(value) => /^\d+$/.test(value)}
|
||||
quickFilters={[
|
||||
{
|
||||
label: '4xx Errors',
|
||||
checked: STATUS_4XX_CODES.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
),
|
||||
onSelect: () => handleQuickStatusCode('4xx'),
|
||||
},
|
||||
{
|
||||
label: '5xx Errors',
|
||||
checked: STATUS_5XX_CODES.every((code) =>
|
||||
selectedStatusCodes.includes(code)
|
||||
),
|
||||
onSelect: () => handleQuickStatusCode('5xx'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>HTTP Methods</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selectedMethods.length > 0 ? (
|
||||
selectedMethods.map((method) => (
|
||||
<FilterBadge
|
||||
key={method}
|
||||
value={method}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
val,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>All methods</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-64 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add method...'
|
||||
value={methodSearch}
|
||||
onValueChange={setMethodSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedMethods.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedMethods.map((method) => (
|
||||
<CommandItem
|
||||
key={`selected-${method}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
method,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{method}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{methodSearch &&
|
||||
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
|
||||
!selectedMethods.includes(methodSearch.toUpperCase()) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
methodSearch.toUpperCase(),
|
||||
onMethodsChange
|
||||
);
|
||||
setMethodSearch('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{methodSearch.toUpperCase()}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{METHOD_OPTIONS.filter(
|
||||
(method) => !selectedMethods.includes(method)
|
||||
).map((method) => (
|
||||
<CommandItem
|
||||
key={method}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedMethods,
|
||||
method,
|
||||
onMethodsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{method}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='HTTP Methods'
|
||||
emptyLabel='All methods'
|
||||
selectedValues={selectedMethods}
|
||||
onSelectedValuesChange={onMethodsChange}
|
||||
options={METHOD_OPTIONS}
|
||||
searchValue={methodSearch}
|
||||
onSearchValueChange={setMethodSearch}
|
||||
searchPlaceholder='Search or add method...'
|
||||
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Methods'
|
||||
normalizeCustomValue={(value) => value.toUpperCase()}
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>Endpoints</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1 overflow-hidden'>
|
||||
{selectedEndpoints.length > 0 ? (
|
||||
selectedEndpoints.map((endpoint) => (
|
||||
<FilterBadge
|
||||
key={endpoint}
|
||||
value={endpoint}
|
||||
onRemove={(val) =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
val,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>
|
||||
All endpoints
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-80 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search or add endpoint pattern...'
|
||||
value={endpointSearch}
|
||||
onValueChange={setEndpointSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedEndpoints.length > 0 && (
|
||||
<CommandGroup heading='Selected'>
|
||||
{selectedEndpoints.map((endpoint) => (
|
||||
<CommandItem
|
||||
key={`selected-${endpoint}`}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpoint,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{endpoint}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{endpointSearch &&
|
||||
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
|
||||
!selectedEndpoints.includes(endpointSearch) && (
|
||||
<CommandGroup heading='Custom'>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpointSearch,
|
||||
onEndpointsChange
|
||||
);
|
||||
setEndpointSearch('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{endpointSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading='Common Endpoints'>
|
||||
{ENDPOINT_OPTIONS.filter(
|
||||
(endpoint) => !selectedEndpoints.includes(endpoint)
|
||||
).map((endpoint) => (
|
||||
<CommandItem
|
||||
key={endpoint}
|
||||
onSelect={() =>
|
||||
toggleSelection(
|
||||
selectedEndpoints,
|
||||
endpoint,
|
||||
onEndpointsChange
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{endpoint}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<MultiSelectCommandFilter
|
||||
label='Endpoints'
|
||||
emptyLabel='All endpoints'
|
||||
selectedValues={selectedEndpoints}
|
||||
onSelectedValuesChange={onEndpointsChange}
|
||||
options={ENDPOINT_OPTIONS}
|
||||
searchValue={endpointSearch}
|
||||
onSearchValueChange={setEndpointSearch}
|
||||
searchPlaceholder='Search or add endpoint pattern...'
|
||||
popoverClassName='w-[min(20rem,calc(100vw-2rem))] p-0'
|
||||
optionsGroupLabel='Common Endpoints'
|
||||
/>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='request-id'>Request ID</Label>
|
||||
@@ -652,7 +346,7 @@ export function LogFilters({
|
||||
type='text'
|
||||
placeholder='Search by request ID'
|
||||
value={requestId}
|
||||
onChange={(e) => onRequestIdChange(e.target.value)}
|
||||
onChange={(event) => onRequestIdChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -668,14 +362,14 @@ export function LogFilters({
|
||||
type='text'
|
||||
placeholder='Search in message and name'
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchTextChange(e.target.value)}
|
||||
onChange={(event) => onSearchTextChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='limit'>Limit</Label>
|
||||
{isCustom ? (
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
id='limit'
|
||||
type='number'
|
||||
@@ -686,7 +380,7 @@ export function LogFilters({
|
||||
onKeyDown={handleCustomLimitKeyDown}
|
||||
onBlur={handleCustomLimitApply}
|
||||
autoFocus
|
||||
className='flex-1'
|
||||
className='flex-1 sm:flex-auto'
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -695,6 +389,7 @@ export function LogFilters({
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
|
||||
if (!isPreset) {
|
||||
onLimitChange(100);
|
||||
}
|
||||
@@ -712,12 +407,11 @@ export function LogFilters({
|
||||
<SelectValue placeholder='Select limit' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='25'>25</SelectItem>
|
||||
<SelectItem value='50'>50</SelectItem>
|
||||
<SelectItem value='100'>100</SelectItem>
|
||||
<SelectItem value='200'>200</SelectItem>
|
||||
<SelectItem value='500'>500</SelectItem>
|
||||
<SelectItem value='1000'>1000</SelectItem>
|
||||
{PRESET_LIMITS.map((preset) => (
|
||||
<SelectItem key={preset} value={preset}>
|
||||
{preset}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value='custom'>Custom...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -727,8 +421,7 @@ export function LogFilters({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label> </Label>
|
||||
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant='outline'
|
||||
|
||||
182
ui/app/logs/multi-select-command-filter.tsx
Normal file
182
ui/app/logs/multi-select-command-filter.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
interface QuickFilterOption {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface MultiSelectCommandFilterProps {
|
||||
label: string;
|
||||
emptyLabel: string;
|
||||
selectedValues: string[];
|
||||
onSelectedValuesChange: (values: string[]) => void;
|
||||
options: string[];
|
||||
searchValue: string;
|
||||
onSearchValueChange: (value: string) => void;
|
||||
searchPlaceholder: string;
|
||||
popoverClassName?: string;
|
||||
selectedGroupLabel?: string;
|
||||
customGroupLabel?: string;
|
||||
quickGroupLabel?: string;
|
||||
optionsGroupLabel?: string;
|
||||
quickFilters?: QuickFilterOption[];
|
||||
normalizeCustomValue?: (value: string) => string;
|
||||
canAddCustom?: (value: string) => boolean;
|
||||
}
|
||||
|
||||
function FilterBadge({ value }: { value: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='flex items-center gap-1 px-1 font-normal'
|
||||
>
|
||||
{value}
|
||||
<X className='h-3 w-3 opacity-70' aria-hidden='true' />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectCommandFilter({
|
||||
label,
|
||||
emptyLabel,
|
||||
selectedValues,
|
||||
onSelectedValuesChange,
|
||||
options,
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
searchPlaceholder,
|
||||
popoverClassName = 'w-64 p-0',
|
||||
selectedGroupLabel = 'Selected',
|
||||
customGroupLabel = 'Custom',
|
||||
quickGroupLabel = 'Quick Filters',
|
||||
optionsGroupLabel = 'Options',
|
||||
quickFilters = [],
|
||||
normalizeCustomValue,
|
||||
canAddCustom,
|
||||
}: MultiSelectCommandFilterProps) {
|
||||
const toggleSelection = (value: string) => {
|
||||
if (selectedValues.includes(value)) {
|
||||
onSelectedValuesChange(selectedValues.filter((item) => item !== value));
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectedValuesChange([...selectedValues, value]);
|
||||
};
|
||||
|
||||
const normalizedSearch = normalizeCustomValue
|
||||
? normalizeCustomValue(searchValue)
|
||||
: searchValue;
|
||||
|
||||
const canShowCustomAction =
|
||||
normalizedSearch.length > 0 &&
|
||||
!options.includes(normalizedSearch) &&
|
||||
!selectedValues.includes(normalizedSearch) &&
|
||||
(canAddCustom ? canAddCustom(normalizedSearch) : true);
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label>{label}</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full justify-start text-left font-normal'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1 overflow-hidden'>
|
||||
{selectedValues.length > 0 ? (
|
||||
selectedValues.map((value) => (
|
||||
<FilterBadge key={value} value={value} />
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>{emptyLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={popoverClassName} align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onValueChange={onSearchValueChange}
|
||||
/>
|
||||
<CommandList>
|
||||
{selectedValues.length > 0 && (
|
||||
<CommandGroup heading={selectedGroupLabel}>
|
||||
{selectedValues.map((value) => (
|
||||
<CommandItem
|
||||
key={`selected-${value}`}
|
||||
onSelect={() => toggleSelection(value)}
|
||||
>
|
||||
<Checkbox checked={true} className='mr-2' />
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{canShowCustomAction && (
|
||||
<CommandGroup heading={customGroupLabel}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
toggleSelection(normalizedSearch);
|
||||
onSearchValueChange('');
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add "{normalizedSearch}"
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
{quickFilters.length > 0 && (
|
||||
<CommandGroup heading={quickGroupLabel}>
|
||||
{quickFilters.map((filter) => (
|
||||
<CommandItem key={filter.label} onSelect={filter.onSelect}>
|
||||
<Checkbox checked={filter.checked} className='mr-2' />
|
||||
{filter.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup heading={optionsGroupLabel}>
|
||||
{options
|
||||
.filter((option) => !selectedValues.includes(option))
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
onSelect={() => toggleSelection(option)}
|
||||
>
|
||||
<Checkbox checked={false} className='mr-2' />
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,9 +12,18 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { FileText, RefreshCw } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { LogEntry, LogsResponse } from './types';
|
||||
import { LogFilters } from './log-filters';
|
||||
import { LogEntryCard } from './log-entry-card';
|
||||
@@ -135,126 +142,125 @@ export default function LogsPage() {
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
Boolean(requestId) ||
|
||||
Boolean(searchText) ||
|
||||
selectedStatusCodes.length > 0 ||
|
||||
selectedMethods.length > 0 ||
|
||||
selectedEndpoints.length > 0;
|
||||
|
||||
const activeFilterDescription = [
|
||||
selectedDate !== 'all' ? `date ${selectedDate}` : null,
|
||||
selectedLevel !== 'all' ? `level ${selectedLevel}` : null,
|
||||
requestId ? `request ID ${requestId}` : null,
|
||||
searchText ? `text "${searchText}"` : null,
|
||||
selectedStatusCodes.length > 0
|
||||
? `status ${selectedStatusCodes.join(', ')}`
|
||||
: null,
|
||||
selectedMethods.length > 0 ? `method ${selectedMethods.join(', ')}` : null,
|
||||
selectedEndpoints.length > 0
|
||||
? `endpoint ${selectedEndpoints.join(', ')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='overflow-x-hidden p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
|
||||
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
|
||||
System Logs
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
|
||||
View and filter application logs
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetchLogs()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='self-start'
|
||||
>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='System Logs'
|
||||
description='View and filter application logs.'
|
||||
actions={
|
||||
<Button onClick={() => refetchLogs()} variant='outline' size='sm'>
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<LogFilters
|
||||
selectedDate={selectedDate}
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
selectedStatusCodes={selectedStatusCodes}
|
||||
selectedMethods={selectedMethods}
|
||||
selectedEndpoints={selectedEndpoints}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onStatusCodesChange={setSelectedStatusCodes}
|
||||
onMethodsChange={setSelectedMethods}
|
||||
onEndpointsChange={setSelectedEndpoints}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
<LogFilters
|
||||
selectedDate={selectedDate}
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
selectedStatusCodes={selectedStatusCodes}
|
||||
selectedMethods={selectedMethods}
|
||||
selectedEndpoints={selectedEndpoints}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onStatusCodesChange={setSelectedStatusCodes}
|
||||
onMethodsChange={setSelectedMethods}
|
||||
onEndpointsChange={setSelectedEndpoints}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-lg sm:text-xl'>Log Entries</span>
|
||||
{logsData && (
|
||||
<Badge variant='secondary' className='text-xs sm:text-sm'>
|
||||
{logsData.logs.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{(selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
requestId ||
|
||||
searchText ||
|
||||
selectedStatusCodes.length > 0 ||
|
||||
selectedMethods.length > 0 ||
|
||||
selectedEndpoints.length > 0) && (
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
Showing logs
|
||||
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
||||
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
||||
{requestId && ` with request ID ${requestId}`}
|
||||
{searchText && ` matching "${searchText}"`}
|
||||
{selectedStatusCodes.length > 0 &&
|
||||
` with status ${selectedStatusCodes.join(', ')}`}
|
||||
{selectedMethods.length > 0 &&
|
||||
` with method ${selectedMethods.join(', ')}`}
|
||||
{selectedEndpoints.length > 0 &&
|
||||
` with endpoint ${selectedEndpoints.join(', ')}`}
|
||||
</CardDescription>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>Log Entries</CardTitle>
|
||||
{logsData && (
|
||||
<Badge variant='secondary'>
|
||||
{logsData.logs.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden p-3 sm:p-6'>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<RefreshCw className='h-6 w-6 animate-spin' />
|
||||
<span className='ml-2 text-sm sm:text-base'>
|
||||
Loading logs...
|
||||
</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Showing logs filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{isLoading ? (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`logs-loading-${index}`}
|
||||
className='h-16 w-full rounded-lg'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : logsData?.logs && logsData.logs.length > 0 ? (
|
||||
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
|
||||
<div className='space-y-2 pr-3'>
|
||||
{logsData.logs.map((entry, index) => (
|
||||
<LogEntryCard
|
||||
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
|
||||
entry={entry}
|
||||
onClick={handleLogClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : logsData?.logs && logsData.logs.length > 0 ? (
|
||||
<>
|
||||
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
|
||||
<div className='space-y-2 pr-3'>
|
||||
{logsData.logs.map((entry, index) => (
|
||||
<LogEntryCard
|
||||
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
|
||||
entry={entry}
|
||||
onClick={handleLogClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
|
||||
<p className='text-sm sm:text-base'>No log entries found</p>
|
||||
<p className='text-xs sm:text-sm'>
|
||||
Try adjusting your filters or check back later
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<Empty className='py-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<FileText className='h-4 w-4' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No log entries found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Try adjusting your filters or check back later.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LogDetailsDialog
|
||||
log={selectedLog}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<LogDetailsDialog
|
||||
log={selectedLog}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ModelSelector } from '@/components/ModelSelector';
|
||||
import { ModelTester } from '@/components/ModelTester';
|
||||
import { ApiEndpointTester } from '@/components/ApiEndpointTester';
|
||||
import { ModelSearchFilter } from '@/components/ModelSearchFilter';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertCircle, Users, Globe } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-models-with-providers'],
|
||||
queryFn: () => AdminService.getModelsWithProviders(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const { models = [], groups = [] } = modelsData || {};
|
||||
|
||||
const groupedModels = useMemo(() => {
|
||||
return groupAndSortModelsByProvider(models);
|
||||
}, [models]);
|
||||
|
||||
const groupDataMap = useMemo(() => {
|
||||
return new Map(groups.map((group) => [group.provider, group]));
|
||||
}, [groups]);
|
||||
|
||||
const providerInfo = useMemo(() => {
|
||||
const allProviders = new Set([
|
||||
...Object.keys(groupedModels),
|
||||
...groups.map((g) => g.provider),
|
||||
]);
|
||||
console.log(allProviders);
|
||||
|
||||
return Array.from(allProviders).map((provider) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
const groupData = groupDataMap.get(provider);
|
||||
const activeModels = providerModels.filter(
|
||||
(m) => m.isEnabled && !m.soft_deleted
|
||||
).length;
|
||||
const totalModels = providerModels.length;
|
||||
|
||||
return {
|
||||
provider,
|
||||
activeModels,
|
||||
totalModels,
|
||||
groupData,
|
||||
hasGroupUrl: !!groupData?.group_url,
|
||||
hasGroupApiKey: !!groupData?.group_api_key,
|
||||
};
|
||||
});
|
||||
}, [groupedModels, groupDataMap, groups]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>
|
||||
Model Management & API Testing
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue='manage' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-3'>
|
||||
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
|
||||
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
|
||||
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='manage' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Manage your AI models organized by provider groups. Configure
|
||||
API keys, and organize models by provider groups.
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[60px] w-full' />
|
||||
<Skeleton className='h-[400px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models. Please try refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Tabs defaultValue='all' className='w-full'>
|
||||
<div className='space-y-4'>
|
||||
{/* Provider Tabs Navigation */}
|
||||
<div className='overflow-x-auto rounded-lg border p-1'>
|
||||
<TabsList className='grid w-full max-w-full min-w-max auto-cols-fr grid-flow-col gap-1 sm:gap-2'>
|
||||
<TabsTrigger
|
||||
value='all'
|
||||
className='flex items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<Globe className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
<span className='hidden sm:inline'>All Models</span>
|
||||
<span className='sm:hidden'>All</span>
|
||||
<Badge variant='secondary' className='ml-1 text-xs'>
|
||||
{models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
{providerInfo.map(
|
||||
({ provider, activeModels, totalModels }) => (
|
||||
<TabsTrigger
|
||||
key={provider}
|
||||
value={provider}
|
||||
className='flex min-w-fit items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
|
||||
>
|
||||
<Users className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
<span className='max-w-20 truncate sm:max-w-none'>
|
||||
{provider}
|
||||
</span>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='ml-1 text-xs'
|
||||
>
|
||||
{activeModels}/{totalModels}
|
||||
</Badge>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
)
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* All Models Tab */}
|
||||
<TabsContent value='all'>
|
||||
<div className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Overview of all models across all provider groups.
|
||||
</div>
|
||||
<ModelSearchFilter
|
||||
models={models}
|
||||
onFilteredModelsChange={setFilteredModels}
|
||||
/>
|
||||
<ModelSelector
|
||||
filteredModels={filteredModels}
|
||||
showDeleteAllButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{providerInfo.map(
|
||||
({ provider, totalModels, groupData }) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
|
||||
return (
|
||||
<TabsContent key={provider} value={provider}>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h3 className='flex items-center gap-2 text-lg font-semibold'>
|
||||
<Users className='h-5 w-5' />
|
||||
{provider}
|
||||
</h3>
|
||||
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
|
||||
{providerModels.filter(
|
||||
(m) => m.soft_deleted
|
||||
).length > 0 && (
|
||||
<span className='text-orange-600'>
|
||||
{
|
||||
providerModels.filter(
|
||||
(m) => m.soft_deleted
|
||||
).length
|
||||
}{' '}
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
{groupData?.group_url && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Globe className='h-3 w-3' />
|
||||
{groupData.group_url}
|
||||
</span>
|
||||
)}
|
||||
{totalModels === 0 && (
|
||||
<span className='text-muted-foreground'>
|
||||
No models configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{totalModels === 0 && (
|
||||
<Alert className='mb-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium'>
|
||||
No models found for this provider
|
||||
</p>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>
|
||||
Common issues:
|
||||
</p>
|
||||
<ul className='ml-2 list-inside list-disc space-y-1'>
|
||||
<li>
|
||||
<strong>API credentials:</strong>{' '}
|
||||
Check if the API key is correct
|
||||
and has the right permissions
|
||||
</li>
|
||||
<li>
|
||||
<strong>Base URL:</strong> Verify
|
||||
the base URL is correct for your
|
||||
provider
|
||||
</li>
|
||||
<li>
|
||||
<strong>Network access:</strong>{' '}
|
||||
Ensure the server can reach the
|
||||
provider's API endpoint
|
||||
</li>
|
||||
<li>
|
||||
<strong>Provider status:</strong>{' '}
|
||||
The upstream provider might be
|
||||
temporarily unavailable
|
||||
</li>
|
||||
</ul>
|
||||
{groupData?.group_url && (
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
Current endpoint:{' '}
|
||||
<code className='bg-muted rounded px-1'>
|
||||
{groupData.group_url}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<ModelSelector
|
||||
filterProvider={provider}
|
||||
groupData={groupData}
|
||||
showProviderActions={true}
|
||||
showDeleteAllButton={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-basic' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Test model credentials and connectivity with basic chat
|
||||
completion requests through the secure proxy (resolves CORS
|
||||
and Docker network issues). Models can be tested even without
|
||||
API keys configured (useful for free models or when
|
||||
authentication is handled elsewhere).
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[200px] w-full' />
|
||||
<Skeleton className='h-[100px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for testing. Please try refreshing
|
||||
the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ModelTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-api' className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Comprehensive testing of all OpenAI API endpoints including
|
||||
chat completions, embeddings, image generation, audio
|
||||
synthesis, and model listing through the secure proxy
|
||||
(resolves CORS and Docker network issues). Models can be
|
||||
tested with or without API keys configured.
|
||||
</div>
|
||||
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[300px] w-full' />
|
||||
<Skeleton className='h-[200px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for API testing. Please try
|
||||
refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ApiEndpointTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
253
ui/app/models/page.tsx
Normal file
253
ui/app/models/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ModelSelector } from '@/components/model-selector';
|
||||
import { ModelTester } from '@/components/model-tester';
|
||||
import { ApiEndpointTester } from '@/components/api-endpoint-tester';
|
||||
import { ModelSearchFilter } from '@/components/model-search-filter';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [selectedProviderScope, setSelectedProviderScope] =
|
||||
useState<string>('all');
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-models-with-providers'],
|
||||
queryFn: () => AdminService.getModelsWithProviders(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const { models = [], groups = [] } = modelsData || {};
|
||||
|
||||
const groupedModels = useMemo(
|
||||
() => groupAndSortModelsByProvider(models),
|
||||
[models]
|
||||
);
|
||||
|
||||
const groupDataMap = useMemo(
|
||||
() => new Map(groups.map((group) => [group.provider, group])),
|
||||
[groups]
|
||||
);
|
||||
|
||||
const providerInfo = useMemo(() => {
|
||||
const allProviders = new Set([
|
||||
...Object.keys(groupedModels),
|
||||
...groups.map((group) => group.provider),
|
||||
]);
|
||||
|
||||
return Array.from(allProviders)
|
||||
.map((provider) => {
|
||||
const providerModels = groupedModels[provider] || [];
|
||||
const groupData = groupDataMap.get(provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
totalModels: providerModels.length,
|
||||
disabledModels: providerModels.filter((model) => model.soft_deleted)
|
||||
.length,
|
||||
groupData,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||
}, [groupDataMap, groupedModels, groups]);
|
||||
|
||||
const activeProviderScope = useMemo(() => {
|
||||
if (selectedProviderScope === 'all') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const providerExists = providerInfo.some(
|
||||
(provider) => provider.provider === selectedProviderScope
|
||||
);
|
||||
|
||||
return providerExists ? selectedProviderScope : 'all';
|
||||
}, [providerInfo, selectedProviderScope]);
|
||||
|
||||
const selectedProviderGroup =
|
||||
activeProviderScope === 'all'
|
||||
? undefined
|
||||
: groupDataMap.get(activeProviderScope);
|
||||
|
||||
const scopedModels = useMemo(() => {
|
||||
if (activeProviderScope === 'all') {
|
||||
return models;
|
||||
}
|
||||
|
||||
return models.filter((model) => model.provider === activeProviderScope);
|
||||
}, [activeProviderScope, models]);
|
||||
|
||||
return (
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<PageHeader
|
||||
title='Model Management'
|
||||
description='Manage provider model catalogs and validate endpoints from one place.'
|
||||
/>
|
||||
|
||||
<Tabs defaultValue='manage' className='w-full gap-3 sm:gap-4'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='w-full snap-x snap-mandatory justify-start gap-0.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
>
|
||||
<TabsTrigger
|
||||
value='manage'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
Manage Models
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='test-basic'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
Basic Testing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='test-api'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
API Endpoints
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='manage' className='mt-0'>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-16 w-full' />
|
||||
<Skeleton className='h-[420px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models. Please try refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='flex flex-col gap-2 sm:gap-2.5 md:flex-row md:items-center'>
|
||||
<Select
|
||||
value={activeProviderScope}
|
||||
onValueChange={(value) => {
|
||||
setSelectedProviderScope(value);
|
||||
setFilteredModels(undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full md:w-[220px]'>
|
||||
<SelectValue placeholder='Provider scope' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='start'>
|
||||
<SelectItem value='all'>
|
||||
All providers ({models.length})
|
||||
</SelectItem>
|
||||
{providerInfo.map(({ provider, totalModels }) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider} ({totalModels})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ModelSearchFilter
|
||||
models={scopedModels}
|
||||
onFilteredModelsChange={setFilteredModels}
|
||||
className='w-full min-w-0 flex-1'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
filterProvider={
|
||||
activeProviderScope === 'all'
|
||||
? undefined
|
||||
: activeProviderScope
|
||||
}
|
||||
groupData={selectedProviderGroup}
|
||||
filteredModels={filteredModels}
|
||||
showDeleteAllButton={activeProviderScope === 'all'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-basic' className='mt-0 space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-base font-semibold'>
|
||||
Basic Credential Testing
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Run chat-completion checks through the secure proxy to validate
|
||||
model credentials and endpoint connectivity.
|
||||
</p>
|
||||
</div>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[220px] w-full' />
|
||||
<Skeleton className='h-[120px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for testing. Please try refreshing the
|
||||
page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ModelTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='test-api' className='mt-0 space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-base font-semibold'>
|
||||
OpenAI Endpoint Testing
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Validate chat, embeddings, image, audio, and model-listing
|
||||
endpoints through the secure proxy.
|
||||
</p>
|
||||
</div>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-[320px] w-full' />
|
||||
<Skeleton className='h-[220px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models for API testing. Please try refreshing
|
||||
the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ApiEndpointTester models={models} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
1283
ui/app/page.tsx
1283
ui/app/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,11 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
import { ProtectedRoute } from '@/lib/auth/ProtectedRoute';
|
||||
import { AuthProvider } from '@/lib/auth/auth-context';
|
||||
import { ProtectedRoute } from '@/lib/auth/protected-route';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
interface ProvidersProps {
|
||||
children: ReactNode;
|
||||
@@ -34,12 +35,14 @@ export function Providers({ children }: ProvidersProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
<Toaster position='top-right' />
|
||||
</ProtectedRoute>
|
||||
</AuthProvider>
|
||||
<TooltipProvider>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
<Toaster position='top-right' />
|
||||
</ProtectedRoute>
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,37 +4,30 @@ import * as React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
|
||||
import { AdminSettings } from '@/components/settings/admin-settings';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { Toaster } from 'sonner';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='flex items-center'>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>Settings</h1>
|
||||
</div>
|
||||
<Tabs defaultValue='admin' className='w-full'>
|
||||
<TabsList className='mb-4'>
|
||||
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='server'>
|
||||
<ServerConfigSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value='admin'>
|
||||
<AdminSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<Toaster />
|
||||
</SidebarProvider>
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='Settings'
|
||||
description='Manage admin authentication, service metadata, and upstream forwarding.'
|
||||
/>
|
||||
<Tabs defaultValue='admin' className='w-full'>
|
||||
<TabsList variant='line' className='mb-4 w-full'>
|
||||
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
|
||||
<TabsTrigger value='server'>Server Config</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='server'>
|
||||
<ServerConfigSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value='admin'>
|
||||
<AdminSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
created_at: string;
|
||||
token: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
interface PaginatedTransactionsResponse {
|
||||
transactions: Transaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
const TransactionService = {
|
||||
getAllTransactions: async (): Promise<Transaction[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<Transaction[]>('/api/transactions');
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transactions:', error);
|
||||
throw new Error('Failed to fetch transactions');
|
||||
}
|
||||
},
|
||||
|
||||
getPaginatedTransactions: async (
|
||||
page: number,
|
||||
perPage: number
|
||||
): Promise<PaginatedTransactionsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<PaginatedTransactionsResponse>(
|
||||
`/api/transactions/paginated/${page}/${perPage}`
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch paginated transactions:', error);
|
||||
throw new Error('Failed to fetch paginated transactions');
|
||||
}
|
||||
},
|
||||
|
||||
getRecentTransactions: async (limit: number): Promise<Transaction[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<Transaction[]>(
|
||||
`/api/transactions/recent/${limit}`
|
||||
);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent transactions:', error);
|
||||
throw new Error('Failed to fetch recent transactions');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const perPage = 20;
|
||||
|
||||
// Fetch paginated transactions data
|
||||
const {
|
||||
data: paginationData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['transactions', currentPage, perPage],
|
||||
queryFn: () =>
|
||||
TransactionService.getPaginatedTransactions(currentPage, perPage),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
const transactions = paginationData?.transactions || [];
|
||||
const totalPages = paginationData?.total_pages || 0;
|
||||
const total = paginationData?.total || 0;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatAmount = (amount: string) => {
|
||||
return `${parseInt(amount).toLocaleString()} msats`;
|
||||
};
|
||||
|
||||
const truncateToken = (token: string) => {
|
||||
if (token.length <= 20) return token;
|
||||
return `${token.slice(0, 10)}...${token.slice(-10)}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Token copied to clipboard!');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
toast.error('Failed to copy token');
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className='flex flex-1 flex-col'>
|
||||
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>
|
||||
Transaction History
|
||||
</h1>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
View all Cashu token transactions processed by the system
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='space-y-4'>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className='h-[120px] w-full' />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load transactions.{' '}
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: 'Please check if the server is running and try refreshing the page.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className='py-8 text-center'>
|
||||
<p className='text-muted-foreground'>
|
||||
No transactions found.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Showing {(currentPage - 1) * perPage + 1} to{' '}
|
||||
{Math.min(currentPage * perPage, total)} of {total}{' '}
|
||||
transactions
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-[100px]'>ID</TableHead>
|
||||
<TableHead>Date & Time</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead className='w-[400px]'>
|
||||
Cashu Token
|
||||
</TableHead>
|
||||
<TableHead className='w-[60px]'>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction) => (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{transaction.id.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
{formatDate(transaction.created_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='secondary'>
|
||||
{formatAmount(transaction.amount)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className='max-w-[300px] cursor-pointer truncate rounded px-1 py-0.5 font-mono text-xs hover:bg-gray-100'>
|
||||
{truncateToken(transaction.token)}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='max-w-md break-all'>
|
||||
<p className='font-mono text-xs'>
|
||||
{transaction.token}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
copyToClipboard(transaction.token)
|
||||
}
|
||||
className='h-8 w-8 p-0'
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={goToPrevious}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className='mr-1 h-4 w-4' />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
<div className='flex items-center space-x-1'>
|
||||
{Array.from(
|
||||
{ length: Math.min(5, totalPages) },
|
||||
(_, i) => {
|
||||
const pageNumber =
|
||||
currentPage <= 3
|
||||
? i + 1
|
||||
: currentPage >= totalPages - 2
|
||||
? totalPages - 4 + i
|
||||
: currentPage - 2 + i;
|
||||
|
||||
if (pageNumber < 1 || pageNumber > totalPages)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant={
|
||||
currentPage === pageNumber
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
size='sm'
|
||||
onClick={() => goToPage(pageNumber)}
|
||||
className='h-9 w-9 p-0'
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={goToNext}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className='ml-1 h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,30 +3,34 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ShieldAlertIcon } from 'lucide-react';
|
||||
import { AuthPageShell } from '@/components/auth-page-shell';
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className='bg-background flex min-h-screen flex-col items-center justify-center p-4'>
|
||||
<div className='flex max-w-md flex-col items-center space-y-6 text-center'>
|
||||
<ShieldAlertIcon className='text-destructive h-24 w-24' />
|
||||
|
||||
<h1 className='text-4xl font-bold'>Access Denied</h1>
|
||||
|
||||
<p className='text-muted-foreground text-lg'>
|
||||
You don't have permission to access this page. Please contact
|
||||
your administrator if you believe this is an error.
|
||||
<AuthPageShell
|
||||
title='Access Denied'
|
||||
description="You don't have permission to access this page."
|
||||
>
|
||||
<div className='flex flex-col items-center gap-6'>
|
||||
<ShieldAlertIcon className='text-destructive h-20 w-20' />
|
||||
<p className='text-muted-foreground text-center text-sm'>
|
||||
Contact your administrator if you believe this is an error.
|
||||
</p>
|
||||
|
||||
<div className='flex gap-4'>
|
||||
<Button onClick={() => router.push('/')}>Go to Dashboard</Button>
|
||||
|
||||
<Button variant='outline' onClick={() => router.back()}>
|
||||
<div className='flex w-full flex-col gap-3 sm:w-auto sm:flex-row'>
|
||||
<Button onClick={() => router.push('/')} className='w-full sm:w-auto'>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => router.back()}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "stone",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -17,5 +19,5 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -45,9 +46,10 @@ export function AddModelForm({
|
||||
isOpen,
|
||||
}: AddModelFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
type ManualModelInput = z.input<typeof ManualModelSchema>;
|
||||
|
||||
const form = useForm<ManualModel>({
|
||||
resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<ManualModelInput, unknown, ManualModel>({
|
||||
resolver: zodResolver(ManualModelSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
full_name: '',
|
||||
699
ui/components/api-endpoint-form.tsx
Normal file
699
ui/components/api-endpoint-form.tsx
Normal file
@@ -0,0 +1,699 @@
|
||||
import type { ChangeEvent, Dispatch, SetStateAction } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Image as ImageIcon, List, Mic, MicOff, Volume2 } from 'lucide-react';
|
||||
import type { EndpointType } from '@/components/api-endpoint-types';
|
||||
|
||||
interface ApiEndpointFormProps {
|
||||
selectedEndpoint: EndpointType;
|
||||
maxTokens: number;
|
||||
setMaxTokens: Dispatch<SetStateAction<number>>;
|
||||
temperature: number;
|
||||
setTemperature: Dispatch<SetStateAction<number>>;
|
||||
systemMessage: string;
|
||||
setSystemMessage: Dispatch<SetStateAction<string>>;
|
||||
userMessage: string;
|
||||
setUserMessage: Dispatch<SetStateAction<string>>;
|
||||
|
||||
visionMaxTokens: number;
|
||||
setVisionMaxTokens: Dispatch<SetStateAction<number>>;
|
||||
visionTemperature: number;
|
||||
setVisionTemperature: Dispatch<SetStateAction<number>>;
|
||||
visionSystemMessage: string;
|
||||
setVisionSystemMessage: Dispatch<SetStateAction<string>>;
|
||||
visionUserMessage: string;
|
||||
setVisionUserMessage: Dispatch<SetStateAction<string>>;
|
||||
imageDetail: 'low' | 'high' | 'auto';
|
||||
setImageDetail: Dispatch<SetStateAction<'low' | 'high' | 'auto'>>;
|
||||
selectedImage: File | null;
|
||||
imagePreviewUrl: string | null;
|
||||
onImageUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onRemoveImage: () => void;
|
||||
|
||||
embeddingInput: string;
|
||||
setEmbeddingInput: Dispatch<SetStateAction<string>>;
|
||||
encodingFormat: 'float' | 'base64';
|
||||
setEncodingFormat: Dispatch<SetStateAction<'float' | 'base64'>>;
|
||||
|
||||
imageCount: number;
|
||||
setImageCount: Dispatch<SetStateAction<number>>;
|
||||
imageSize: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
|
||||
setImageSize: Dispatch<
|
||||
SetStateAction<
|
||||
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
|
||||
>
|
||||
>;
|
||||
imageQuality: 'standard' | 'hd';
|
||||
setImageQuality: Dispatch<SetStateAction<'standard' | 'hd'>>;
|
||||
imageStyle: 'vivid' | 'natural';
|
||||
setImageStyle: Dispatch<SetStateAction<'vivid' | 'natural'>>;
|
||||
imagePrompt: string;
|
||||
setImagePrompt: Dispatch<SetStateAction<string>>;
|
||||
|
||||
speechVoice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
|
||||
setSpeechVoice: Dispatch<
|
||||
SetStateAction<'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'>
|
||||
>;
|
||||
speechFormat: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
|
||||
setSpeechFormat: Dispatch<
|
||||
SetStateAction<'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'>
|
||||
>;
|
||||
speechSpeed: number;
|
||||
setSpeechSpeed: Dispatch<SetStateAction<number>>;
|
||||
speechInput: string;
|
||||
setSpeechInput: Dispatch<SetStateAction<string>>;
|
||||
|
||||
audioTranscriptionPrompt: string;
|
||||
setAudioTranscriptionPrompt: Dispatch<SetStateAction<string>>;
|
||||
audioTemperature: number;
|
||||
setAudioTemperature: Dispatch<SetStateAction<number>>;
|
||||
audioLanguage: string;
|
||||
setAudioLanguage: Dispatch<SetStateAction<string>>;
|
||||
audioResponseFormat: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
|
||||
setAudioResponseFormat: Dispatch<
|
||||
SetStateAction<'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'>
|
||||
>;
|
||||
isRecording: boolean;
|
||||
onStartRecording: () => void;
|
||||
onStopRecording: () => void;
|
||||
onAudioUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
recordedAudio: File | null;
|
||||
recordingUrl: string | null;
|
||||
onRemoveAudio: () => void;
|
||||
}
|
||||
|
||||
export function ApiEndpointForm({
|
||||
selectedEndpoint,
|
||||
maxTokens,
|
||||
setMaxTokens,
|
||||
temperature,
|
||||
setTemperature,
|
||||
systemMessage,
|
||||
setSystemMessage,
|
||||
userMessage,
|
||||
setUserMessage,
|
||||
visionMaxTokens,
|
||||
setVisionMaxTokens,
|
||||
visionTemperature,
|
||||
setVisionTemperature,
|
||||
visionSystemMessage,
|
||||
setVisionSystemMessage,
|
||||
visionUserMessage,
|
||||
setVisionUserMessage,
|
||||
imageDetail,
|
||||
setImageDetail,
|
||||
selectedImage,
|
||||
imagePreviewUrl,
|
||||
onImageUpload,
|
||||
onRemoveImage,
|
||||
embeddingInput,
|
||||
setEmbeddingInput,
|
||||
encodingFormat,
|
||||
setEncodingFormat,
|
||||
imageCount,
|
||||
setImageCount,
|
||||
imageSize,
|
||||
setImageSize,
|
||||
imageQuality,
|
||||
setImageQuality,
|
||||
imageStyle,
|
||||
setImageStyle,
|
||||
imagePrompt,
|
||||
setImagePrompt,
|
||||
speechVoice,
|
||||
setSpeechVoice,
|
||||
speechFormat,
|
||||
setSpeechFormat,
|
||||
speechSpeed,
|
||||
setSpeechSpeed,
|
||||
speechInput,
|
||||
setSpeechInput,
|
||||
audioTranscriptionPrompt,
|
||||
setAudioTranscriptionPrompt,
|
||||
audioTemperature,
|
||||
setAudioTemperature,
|
||||
audioLanguage,
|
||||
setAudioLanguage,
|
||||
audioResponseFormat,
|
||||
setAudioResponseFormat,
|
||||
isRecording,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onAudioUpload,
|
||||
recordedAudio,
|
||||
recordingUrl,
|
||||
onRemoveAudio,
|
||||
}: ApiEndpointFormProps) {
|
||||
switch (selectedEndpoint) {
|
||||
case 'chat-completions':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='max-tokens'>Max Tokens</Label>
|
||||
<Input
|
||||
id='max-tokens'
|
||||
type='number'
|
||||
min={1}
|
||||
max={4000}
|
||||
value={maxTokens}
|
||||
onChange={(event) =>
|
||||
setMaxTokens(parseInt(event.target.value) || 150)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='temperature'>Temperature</Label>
|
||||
<Input
|
||||
id='temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(event) =>
|
||||
setTemperature(parseFloat(event.target.value) || 0.7)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='system-message'>System Message (Optional)</Label>
|
||||
<Textarea
|
||||
id='system-message'
|
||||
placeholder='Enter system message...'
|
||||
value={systemMessage}
|
||||
onChange={(event) => setSystemMessage(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='user-message'>Test Message</Label>
|
||||
<Textarea
|
||||
id='user-message'
|
||||
placeholder='Enter your test message...'
|
||||
value={userMessage}
|
||||
onChange={(event) => setUserMessage(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'vision-chat':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-max-tokens'>Max Tokens</Label>
|
||||
<Input
|
||||
id='vision-max-tokens'
|
||||
type='number'
|
||||
min={1}
|
||||
max={4000}
|
||||
value={visionMaxTokens}
|
||||
onChange={(event) =>
|
||||
setVisionMaxTokens(parseInt(event.target.value) || 300)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-temperature'>Temperature</Label>
|
||||
<Input
|
||||
id='vision-temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={visionTemperature}
|
||||
onChange={(event) =>
|
||||
setVisionTemperature(parseFloat(event.target.value) || 0.7)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-system-message'>
|
||||
System Message (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id='vision-system-message'
|
||||
placeholder='Enter system message...'
|
||||
value={visionSystemMessage}
|
||||
onChange={(event) => setVisionSystemMessage(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='vision-user-message'>Test Message</Label>
|
||||
<Textarea
|
||||
id='vision-user-message'
|
||||
placeholder='Enter your test message...'
|
||||
value={visionUserMessage}
|
||||
onChange={(event) => setVisionUserMessage(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-detail'>Image Detail</Label>
|
||||
<Select
|
||||
value={imageDetail}
|
||||
onValueChange={(value: 'low' | 'high' | 'auto') =>
|
||||
setImageDetail(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='low'>Low</SelectItem>
|
||||
<SelectItem value='high'>High</SelectItem>
|
||||
<SelectItem value='auto'>Auto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-upload'>Upload Image (Optional)</Label>
|
||||
<Input
|
||||
type='file'
|
||||
accept='image/*'
|
||||
onChange={onImageUpload}
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
{selectedImage && (
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
<ImageIcon className='text-muted-foreground h-5 w-5' />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Selected image: {selectedImage.name}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onRemoveImage}
|
||||
className='ml-0 sm:ml-auto'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{imagePreviewUrl && (
|
||||
<div className='relative mt-2 aspect-square w-32 overflow-hidden rounded-md border'>
|
||||
<Image
|
||||
src={imagePreviewUrl}
|
||||
alt='Image preview'
|
||||
fill
|
||||
className='object-cover'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'embeddings':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='encoding-format'>Encoding Format</Label>
|
||||
<Select
|
||||
value={encodingFormat}
|
||||
onValueChange={(value: 'float' | 'base64') =>
|
||||
setEncodingFormat(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='float'>Float</SelectItem>
|
||||
<SelectItem value='base64'>Base64</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='embedding-input'>Text Input</Label>
|
||||
<Textarea
|
||||
id='embedding-input'
|
||||
placeholder='Enter text to generate embeddings for...'
|
||||
value={embeddingInput}
|
||||
onChange={(event) => setEmbeddingInput(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'images':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-count'>Number of Images</Label>
|
||||
<Input
|
||||
id='image-count'
|
||||
type='number'
|
||||
min={1}
|
||||
max={10}
|
||||
value={imageCount}
|
||||
onChange={(event) =>
|
||||
setImageCount(parseInt(event.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-size'>Size</Label>
|
||||
<Select
|
||||
value={imageSize}
|
||||
onValueChange={(
|
||||
value:
|
||||
| '256x256'
|
||||
| '512x512'
|
||||
| '1024x1024'
|
||||
| '1792x1024'
|
||||
| '1024x1792'
|
||||
) => setImageSize(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='256x256'>256x256</SelectItem>
|
||||
<SelectItem value='512x512'>512x512</SelectItem>
|
||||
<SelectItem value='1024x1024'>1024x1024</SelectItem>
|
||||
<SelectItem value='1792x1024'>1792x1024</SelectItem>
|
||||
<SelectItem value='1024x1792'>1024x1792</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-quality'>Quality</Label>
|
||||
<Select
|
||||
value={imageQuality}
|
||||
onValueChange={(value: 'standard' | 'hd') =>
|
||||
setImageQuality(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='standard'>Standard</SelectItem>
|
||||
<SelectItem value='hd'>HD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-style'>Style</Label>
|
||||
<Select
|
||||
value={imageStyle}
|
||||
onValueChange={(value: 'vivid' | 'natural') =>
|
||||
setImageStyle(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='vivid'>Vivid</SelectItem>
|
||||
<SelectItem value='natural'>Natural</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='image-prompt'>Image Prompt</Label>
|
||||
<Textarea
|
||||
id='image-prompt'
|
||||
placeholder='Describe the image you want to generate...'
|
||||
value={imagePrompt}
|
||||
onChange={(event) => setImagePrompt(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio-speech':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-voice'>Voice</Label>
|
||||
<Select
|
||||
value={speechVoice}
|
||||
onValueChange={(
|
||||
value:
|
||||
| 'alloy'
|
||||
| 'echo'
|
||||
| 'fable'
|
||||
| 'onyx'
|
||||
| 'nova'
|
||||
| 'shimmer'
|
||||
) => setSpeechVoice(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='alloy'>Alloy</SelectItem>
|
||||
<SelectItem value='echo'>Echo</SelectItem>
|
||||
<SelectItem value='fable'>Fable</SelectItem>
|
||||
<SelectItem value='onyx'>Onyx</SelectItem>
|
||||
<SelectItem value='nova'>Nova</SelectItem>
|
||||
<SelectItem value='shimmer'>Shimmer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-format'>Response Format</Label>
|
||||
<Select
|
||||
value={speechFormat}
|
||||
onValueChange={(
|
||||
value: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
|
||||
) => setSpeechFormat(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='mp3'>MP3</SelectItem>
|
||||
<SelectItem value='opus'>Opus</SelectItem>
|
||||
<SelectItem value='aac'>AAC</SelectItem>
|
||||
<SelectItem value='flac'>FLAC</SelectItem>
|
||||
<SelectItem value='wav'>WAV</SelectItem>
|
||||
<SelectItem value='pcm'>PCM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-speed'>Speed</Label>
|
||||
<Input
|
||||
id='speech-speed'
|
||||
type='number'
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.25}
|
||||
value={speechSpeed}
|
||||
onChange={(event) =>
|
||||
setSpeechSpeed(parseFloat(event.target.value) || 1.0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='speech-input'>Text to Synthesize</Label>
|
||||
<Textarea
|
||||
id='speech-input'
|
||||
placeholder='Enter text to convert to speech...'
|
||||
value={speechInput}
|
||||
onChange={(event) => setSpeechInput(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio-transcription':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-prompt'>
|
||||
Prompt (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id='audio-transcription-prompt'
|
||||
placeholder='Enter a prompt for the transcription...'
|
||||
value={audioTranscriptionPrompt}
|
||||
onChange={(event) =>
|
||||
setAudioTranscriptionPrompt(event.target.value)
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-temperature'>
|
||||
Temperature
|
||||
</Label>
|
||||
<Input
|
||||
id='audio-transcription-temperature'
|
||||
type='number'
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={audioTemperature}
|
||||
onChange={(event) =>
|
||||
setAudioTemperature(parseFloat(event.target.value) || 0.0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-language'>
|
||||
Language (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id='audio-transcription-language'
|
||||
placeholder='e.g., en-US, fr-FR'
|
||||
value={audioLanguage}
|
||||
onChange={(event) => setAudioLanguage(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-response-format'>
|
||||
Response Format
|
||||
</Label>
|
||||
<Select
|
||||
value={audioResponseFormat}
|
||||
onValueChange={(
|
||||
value: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
|
||||
) => setAudioResponseFormat(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='json'>JSON</SelectItem>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
<SelectItem value='srt'>SRT</SelectItem>
|
||||
<SelectItem value='verbose_json'>Verbose JSON</SelectItem>
|
||||
<SelectItem value='vtt'>VTT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Voice Recording</Label>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant={isRecording ? 'destructive' : 'default'}
|
||||
size='sm'
|
||||
onClick={isRecording ? onStopRecording : onStartRecording}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<MicOff className='h-4 w-4' />
|
||||
Stop Recording
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className='h-4 w-4' />
|
||||
Start Recording
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isRecording && (
|
||||
<Badge variant='destructive' className='gap-1.5'>
|
||||
<span className='h-2 w-2 animate-pulse rounded-full bg-current' />
|
||||
Recording...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='audio-transcription-upload'>
|
||||
Or Upload Audio File
|
||||
</Label>
|
||||
<Input
|
||||
type='file'
|
||||
accept='audio/*'
|
||||
onChange={onAudioUpload}
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
{recordedAudio && (
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
<Volume2 className='text-muted-foreground h-5 w-5' />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Selected audio: {recordedAudio.name}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onRemoveAudio}
|
||||
className='ml-0 sm:ml-auto'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{recordingUrl && (
|
||||
<div className='bg-muted mt-2 rounded-md p-4'>
|
||||
<audio controls src={recordingUrl} className='w-full'>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'models':
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Alert>
|
||||
<List className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
This endpoint lists all available models from the provider. No
|
||||
additional parameters are required.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
306
ui/components/api-endpoint-response.tsx
Normal file
306
ui/components/api-endpoint-response.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import Image from 'next/image';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: {
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
finish_reason: string;
|
||||
}[];
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmbeddingResponse {
|
||||
object: string;
|
||||
data: {
|
||||
object: string;
|
||||
index: number;
|
||||
embedding: number[];
|
||||
}[];
|
||||
model: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
created: number;
|
||||
data: {
|
||||
url: string;
|
||||
revised_prompt?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AudioResponse {
|
||||
type: 'audio';
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionResponse {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ModelsListResponse {
|
||||
object: string;
|
||||
data: {
|
||||
id: string;
|
||||
object?: string;
|
||||
created?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type ApiResponse =
|
||||
| ChatCompletionResponse
|
||||
| EmbeddingResponse
|
||||
| ImageGenerationResponse
|
||||
| AudioResponse
|
||||
| AudioTranscriptionResponse
|
||||
| ModelsListResponse;
|
||||
|
||||
const isAudioResponse = (response: ApiResponse): response is AudioResponse => {
|
||||
return 'type' in response && response.type === 'audio';
|
||||
};
|
||||
|
||||
const isChatCompletionResponse = (
|
||||
response: ApiResponse
|
||||
): response is ChatCompletionResponse => {
|
||||
return 'choices' in response;
|
||||
};
|
||||
|
||||
const isEmbeddingResponse = (
|
||||
response: ApiResponse
|
||||
): response is EmbeddingResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'embedding' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isImageGenerationResponse = (
|
||||
response: ApiResponse
|
||||
): response is ImageGenerationResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'url' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isModelsListResponse = (
|
||||
response: ApiResponse
|
||||
): response is ModelsListResponse => {
|
||||
return (
|
||||
'data' in response &&
|
||||
Array.isArray(response.data) &&
|
||||
response.data.length > 0 &&
|
||||
'id' in response.data[0]
|
||||
);
|
||||
};
|
||||
|
||||
const isAudioTranscriptionResponse = (
|
||||
response: ApiResponse
|
||||
): response is AudioTranscriptionResponse => {
|
||||
return 'text' in response;
|
||||
};
|
||||
|
||||
export function ApiEndpointResponse({
|
||||
response,
|
||||
}: {
|
||||
response: ApiResponse | null;
|
||||
}) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAudioResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Generated Audio</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<audio controls src={response.url} className='w-full'>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
Audio file size: {(response.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAudioTranscriptionResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Transcription Result</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-sm whitespace-pre-wrap'>
|
||||
{response.text || 'No transcription result available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isChatCompletionResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Model Response</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-sm whitespace-pre-wrap'>
|
||||
{response.choices?.[0]?.message?.content ||
|
||||
'No content in response'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{response.usage && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Usage Statistics</Label>
|
||||
<div className='grid grid-cols-1 gap-2 text-sm sm:grid-cols-3 sm:gap-4'>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.prompt_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Prompt Tokens</div>
|
||||
</div>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.completion_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Completion Tokens</div>
|
||||
</div>
|
||||
<div className='bg-muted rounded p-2 text-center'>
|
||||
<div className='font-semibold'>
|
||||
{response.usage.total_tokens}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Total Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmbeddingResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Embedding Vector</Label>
|
||||
<div className='bg-muted rounded-md p-4'>
|
||||
<p className='text-muted-foreground mb-2 text-sm'>
|
||||
Generated {response.data?.[0]?.embedding?.length || 0} dimensional
|
||||
embedding vector
|
||||
</p>
|
||||
<details className='group'>
|
||||
<summary className='hover:text-foreground cursor-pointer text-sm'>
|
||||
Show first 10 values
|
||||
</summary>
|
||||
<pre className='mt-2 text-xs'>
|
||||
{JSON.stringify(
|
||||
response.data?.[0]?.embedding?.slice(0, 10),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
...
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{response.usage && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Usage Statistics</Label>
|
||||
<div className='bg-muted rounded p-2 text-center text-sm'>
|
||||
<div className='font-semibold'>{response.usage.total_tokens}</div>
|
||||
<div className='text-muted-foreground'>Total Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImageGenerationResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Generated Images</Label>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
{response.data?.map((image, index: number) => (
|
||||
<div key={index} className='space-y-2'>
|
||||
<div className='relative aspect-square w-full'>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={`Generated image ${index + 1}`}
|
||||
fill
|
||||
className='rounded-md border object-cover'
|
||||
unoptimized={true}
|
||||
/>
|
||||
</div>
|
||||
{image.revised_prompt && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Revised prompt: {image.revised_prompt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isModelsListResponse(response)) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Available Models</Label>
|
||||
<div className='max-h-60 overflow-auto'>
|
||||
<div className='grid gap-2'>
|
||||
{response.data?.map((model) => (
|
||||
<div key={model.id} className='bg-muted rounded-md p-3'>
|
||||
<div className='font-medium'>{model.id}</div>
|
||||
{model.object && (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Type: {model.object}
|
||||
</div>
|
||||
)}
|
||||
{model.created && (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Created:{' '}
|
||||
{new Date(model.created * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
726
ui/components/api-endpoint-tester.tsx
Normal file
726
ui/components/api-endpoint-tester.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { type Model } from '@/lib/api/schemas/models';
|
||||
import { ModelService } from '@/lib/api/services/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Loader2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info,
|
||||
Key,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ApiEndpointResponse,
|
||||
type ApiResponse,
|
||||
} from '@/components/api-endpoint-response';
|
||||
import { ApiEndpointForm } from '@/components/api-endpoint-form';
|
||||
import {
|
||||
API_ENDPOINTS,
|
||||
DEFAULT_REQUESTS,
|
||||
type EndpointType,
|
||||
type ChatCompletionRequest,
|
||||
type EmbeddingRequest,
|
||||
type ImageGenerationRequest,
|
||||
type AudioSpeechRequest,
|
||||
type AudioTranscriptionRequest,
|
||||
type EndpointRequestData,
|
||||
} from '@/components/api-endpoint-types';
|
||||
|
||||
interface ApiEndpointTesterProps {
|
||||
models: Model[];
|
||||
}
|
||||
|
||||
export function ApiEndpointTester({ models }: ApiEndpointTesterProps) {
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
||||
const [selectedEndpoint, setSelectedEndpoint] =
|
||||
useState<EndpointType>('chat-completions');
|
||||
const [response, setResponse] = useState<ApiResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Chat Completions state
|
||||
const [systemMessage, setSystemMessage] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].systemMessage
|
||||
);
|
||||
const [userMessage, setUserMessage] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].userMessage
|
||||
);
|
||||
const [maxTokens, setMaxTokens] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].maxTokens
|
||||
);
|
||||
const [temperature, setTemperature] = useState(
|
||||
DEFAULT_REQUESTS['chat-completions'].temperature
|
||||
);
|
||||
|
||||
// Vision Chat state
|
||||
const [visionSystemMessage, setVisionSystemMessage] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].systemMessage
|
||||
);
|
||||
const [visionUserMessage, setVisionUserMessage] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].userMessage
|
||||
);
|
||||
const [visionMaxTokens, setVisionMaxTokens] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].maxTokens
|
||||
);
|
||||
const [visionTemperature, setVisionTemperature] = useState(
|
||||
DEFAULT_REQUESTS['vision-chat'].temperature
|
||||
);
|
||||
const [imageDetail, setImageDetail] = useState<'low' | 'high' | 'auto'>(
|
||||
DEFAULT_REQUESTS['vision-chat'].imageDetail
|
||||
);
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// Voice Recording state
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordedAudio, setRecordedAudio] = useState<File | null>(null);
|
||||
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
|
||||
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(
|
||||
null
|
||||
);
|
||||
const [audioTranscriptionPrompt, setAudioTranscriptionPrompt] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].prompt
|
||||
);
|
||||
const [audioResponseFormat, setAudioResponseFormat] = useState<
|
||||
'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
|
||||
>(DEFAULT_REQUESTS['audio-transcription'].response_format);
|
||||
const [audioTemperature, setAudioTemperature] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].temperature
|
||||
);
|
||||
const [audioLanguage, setAudioLanguage] = useState(
|
||||
DEFAULT_REQUESTS['audio-transcription'].language
|
||||
);
|
||||
|
||||
// Embeddings state
|
||||
const [embeddingInput, setEmbeddingInput] = useState(
|
||||
DEFAULT_REQUESTS.embeddings.input
|
||||
);
|
||||
const [encodingFormat, setEncodingFormat] = useState<'float' | 'base64'>(
|
||||
DEFAULT_REQUESTS.embeddings.encoding_format
|
||||
);
|
||||
|
||||
// Image Generation state
|
||||
const [imagePrompt, setImagePrompt] = useState(
|
||||
DEFAULT_REQUESTS.images.prompt
|
||||
);
|
||||
const [imageCount, setImageCount] = useState(DEFAULT_REQUESTS.images.n);
|
||||
const [imageSize, setImageSize] = useState<
|
||||
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
|
||||
>(DEFAULT_REQUESTS.images.size);
|
||||
const [imageQuality, setImageQuality] = useState<'standard' | 'hd'>(
|
||||
DEFAULT_REQUESTS.images.quality
|
||||
);
|
||||
const [imageStyle, setImageStyle] = useState<'vivid' | 'natural'>(
|
||||
DEFAULT_REQUESTS.images.style
|
||||
);
|
||||
|
||||
// Audio Speech state
|
||||
const [speechInput, setSpeechInput] = useState(
|
||||
DEFAULT_REQUESTS['audio-speech'].input
|
||||
);
|
||||
const [speechVoice, setSpeechVoice] = useState<
|
||||
'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'
|
||||
>(DEFAULT_REQUESTS['audio-speech'].voice);
|
||||
const [speechFormat, setSpeechFormat] = useState<
|
||||
'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
|
||||
>(DEFAULT_REQUESTS['audio-speech'].response_format);
|
||||
const [speechSpeed, setSpeechSpeed] = useState(
|
||||
DEFAULT_REQUESTS['audio-speech'].speed
|
||||
);
|
||||
|
||||
// Fetch model groups for API key resolution
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['model-groups'],
|
||||
queryFn: () => ModelService.getModelGroups(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const selectedModel = models.find((model) => model.id === selectedModelId);
|
||||
|
||||
// Image upload handler
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
setSelectedImage(file);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setImagePreviewUrl(previewUrl);
|
||||
} else {
|
||||
toast.error('Please select a valid image file');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Audio file upload handler
|
||||
const handleAudioUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type.startsWith('audio/')) {
|
||||
setRecordedAudio(file);
|
||||
const audioUrl = URL.createObjectURL(file);
|
||||
setRecordingUrl(audioUrl);
|
||||
} else {
|
||||
toast.error('Please select a valid audio file');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Voice recording functions
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: BlobPart[] = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' });
|
||||
const file = new File([blob], 'recording.webm', { type: 'audio/webm' });
|
||||
setRecordedAudio(file);
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
setRecordingUrl(audioUrl);
|
||||
|
||||
// Stop all tracks to release microphone
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setMediaRecorder(recorder);
|
||||
setIsRecording(true);
|
||||
toast.success('Recording started');
|
||||
} catch {
|
||||
toast.error(
|
||||
'Failed to start recording. Please check microphone permissions.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder) {
|
||||
mediaRecorder.stop();
|
||||
setMediaRecorder(null);
|
||||
setIsRecording(false);
|
||||
toast.success('Recording stopped');
|
||||
}
|
||||
};
|
||||
|
||||
// Convert file to base64 for vision API
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Get effective API key and endpoint URL for the selected model
|
||||
const getModelCredentials = (model: Model) => {
|
||||
const group = groups.find((g) => g.provider === model.provider);
|
||||
|
||||
// Determine API key (individual takes precedence over group)
|
||||
const apiKey = model.api_key || group?.group_api_key;
|
||||
|
||||
// Determine base endpoint URL
|
||||
let baseUrl = model.url;
|
||||
|
||||
// If model URL is relative and group has a base URL, combine them
|
||||
if (model.url.startsWith('/') && group?.group_url) {
|
||||
baseUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`;
|
||||
}
|
||||
|
||||
// Remove any existing endpoint path to get base URL
|
||||
baseUrl = baseUrl.replace(/\/v1\/.*$/, '').replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
group,
|
||||
};
|
||||
};
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string, endpointPath: string) => {
|
||||
return `${baseUrl}${endpointPath}`;
|
||||
};
|
||||
|
||||
const buildRequest = async (): Promise<EndpointRequestData> => {
|
||||
if (!selectedModel) return null;
|
||||
|
||||
switch (selectedEndpoint) {
|
||||
case 'chat-completions':
|
||||
const messages = [];
|
||||
if (systemMessage.trim()) {
|
||||
messages.push({
|
||||
role: 'system' as const,
|
||||
content: systemMessage.trim(),
|
||||
});
|
||||
}
|
||||
messages.push({ role: 'user' as const, content: userMessage.trim() });
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
messages,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
} as ChatCompletionRequest;
|
||||
|
||||
case 'vision-chat':
|
||||
if (!selectedImage) {
|
||||
throw new Error('Please select an image for vision analysis');
|
||||
}
|
||||
|
||||
const imageBase64 = await fileToBase64(selectedImage);
|
||||
const visionMessages = [];
|
||||
|
||||
if (visionSystemMessage.trim()) {
|
||||
visionMessages.push({
|
||||
role: 'system' as const,
|
||||
content: visionSystemMessage.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
visionMessages.push({
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: visionUserMessage.trim(),
|
||||
},
|
||||
{
|
||||
type: 'image_url' as const,
|
||||
image_url: {
|
||||
url: imageBase64,
|
||||
detail: imageDetail,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
messages: visionMessages,
|
||||
max_tokens: visionMaxTokens,
|
||||
temperature: visionTemperature,
|
||||
} as ChatCompletionRequest;
|
||||
|
||||
case 'embeddings':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
input: embeddingInput,
|
||||
encoding_format: encodingFormat,
|
||||
} as EmbeddingRequest;
|
||||
|
||||
case 'images':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
prompt: imagePrompt,
|
||||
n: imageCount,
|
||||
size: imageSize,
|
||||
quality: imageQuality,
|
||||
style: imageStyle,
|
||||
} as ImageGenerationRequest;
|
||||
|
||||
case 'audio-speech':
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
input: speechInput,
|
||||
voice: speechVoice,
|
||||
response_format: speechFormat,
|
||||
speed: speechSpeed,
|
||||
} as AudioSpeechRequest;
|
||||
|
||||
case 'audio-transcription':
|
||||
if (!recordedAudio) {
|
||||
throw new Error(
|
||||
'Please record or upload an audio file for transcription'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
model: selectedModel.name,
|
||||
file: recordedAudio,
|
||||
prompt: audioTranscriptionPrompt.trim() || undefined,
|
||||
response_format: audioResponseFormat,
|
||||
temperature: audioTemperature,
|
||||
language: audioLanguage.trim() || undefined,
|
||||
} as AudioTranscriptionRequest;
|
||||
|
||||
case 'models':
|
||||
return null; // No request body needed for models endpoint
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const testEndpointMutation = useMutation({
|
||||
mutationFn: async (requestData: EndpointRequestData) => {
|
||||
if (!selectedModel) {
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setResponse(null);
|
||||
|
||||
try {
|
||||
const response = await ModelService.testModel(
|
||||
selectedModel.id,
|
||||
selectedEndpoint,
|
||||
requestData
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Test failed');
|
||||
}
|
||||
|
||||
return response.data as ApiResponse;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to test endpoint via proxy';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResponse(data);
|
||||
toast.success(
|
||||
`${API_ENDPOINTS[selectedEndpoint].name} test completed successfully!`
|
||||
);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
const errorMessage = err?.message || 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
toast.error(
|
||||
`${API_ENDPOINTS[selectedEndpoint].name} test failed: ${errorMessage}`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!selectedModel) {
|
||||
toast.error('Please select a model to test');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields based on endpoint
|
||||
if (selectedEndpoint === 'chat-completions' && !userMessage.trim()) {
|
||||
toast.error('Please enter a test message');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'vision-chat') {
|
||||
if (!visionUserMessage.trim()) {
|
||||
toast.error('Please enter a test message');
|
||||
return;
|
||||
}
|
||||
if (!selectedImage) {
|
||||
toast.error('Please select an image for vision analysis');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'embeddings' && !embeddingInput.trim()) {
|
||||
toast.error('Please enter text for embedding');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'images' && !imagePrompt.trim()) {
|
||||
toast.error('Please enter an image prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'audio-speech' && !speechInput.trim()) {
|
||||
toast.error('Please enter text for speech synthesis');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEndpoint === 'audio-transcription' && !recordedAudio) {
|
||||
toast.error('Please record or upload an audio file for transcription');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = await buildRequest();
|
||||
testEndpointMutation.mutate(requestData);
|
||||
};
|
||||
|
||||
const enabledModels = models.filter((model) => model.isEnabled);
|
||||
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
||||
const endpointUrl = credentials
|
||||
? buildEndpointUrl(
|
||||
credentials.baseUrl,
|
||||
API_ENDPOINTS[selectedEndpoint].path
|
||||
)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Card className='w-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>API Endpoint Tester</CardTitle>
|
||||
<CardDescription>
|
||||
Comprehensive testing of OpenAI-compatible API endpoints through the
|
||||
secure proxy (resolves CORS and network issues)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
{/* Model and Endpoint Selection */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-select'>Select Model</Label>
|
||||
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
|
||||
<SelectTrigger id='model-select'>
|
||||
<SelectValue placeholder='Choose a model to test...' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{model.name}</span>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
{model.provider}
|
||||
</Badge>
|
||||
{model.is_free && (
|
||||
<Badge variant='secondary' className='text-xs'>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='endpoint-select'>API Endpoint</Label>
|
||||
<Select
|
||||
value={selectedEndpoint}
|
||||
onValueChange={(value: EndpointType) =>
|
||||
setSelectedEndpoint(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id='endpoint-select'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(API_ENDPOINTS).map(([key, endpoint]) => {
|
||||
const Icon = endpoint.icon;
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon className='h-4 w-4' />
|
||||
<span>{endpoint.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Information */}
|
||||
{selectedModel && credentials && (
|
||||
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Globe className='h-4 w-4' />
|
||||
<span className='break-all'>
|
||||
<strong>Endpoint:</strong> {endpointUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
<span>
|
||||
<strong>API Key:</strong>{' '}
|
||||
{credentials.apiKey
|
||||
? `${credentials.apiKey.substring(0, 8)}...`
|
||||
: 'Not configured'}
|
||||
</span>
|
||||
<Badge
|
||||
variant={credentials.apiKey ? 'default' : 'destructive'}
|
||||
className='text-xs'
|
||||
>
|
||||
{selectedModel.api_key_type || 'Unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<strong>Provider:</strong> {selectedModel.provider}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<strong>Description:</strong>{' '}
|
||||
{API_ENDPOINTS[selectedEndpoint].description}
|
||||
</span>
|
||||
</div>
|
||||
{!credentials.apiKey && (
|
||||
<Alert variant='default' className='mt-2'>
|
||||
<Info className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
No API key configured for this model. Testing may still work
|
||||
if the model is free or if authentication is handled
|
||||
elsewhere. For models requiring authentication, please add an
|
||||
API key to the model or its provider group.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ApiEndpointForm
|
||||
selectedEndpoint={selectedEndpoint}
|
||||
maxTokens={maxTokens}
|
||||
setMaxTokens={setMaxTokens}
|
||||
temperature={temperature}
|
||||
setTemperature={setTemperature}
|
||||
systemMessage={systemMessage}
|
||||
setSystemMessage={setSystemMessage}
|
||||
userMessage={userMessage}
|
||||
setUserMessage={setUserMessage}
|
||||
visionMaxTokens={visionMaxTokens}
|
||||
setVisionMaxTokens={setVisionMaxTokens}
|
||||
visionTemperature={visionTemperature}
|
||||
setVisionTemperature={setVisionTemperature}
|
||||
visionSystemMessage={visionSystemMessage}
|
||||
setVisionSystemMessage={setVisionSystemMessage}
|
||||
visionUserMessage={visionUserMessage}
|
||||
setVisionUserMessage={setVisionUserMessage}
|
||||
imageDetail={imageDetail}
|
||||
setImageDetail={setImageDetail}
|
||||
selectedImage={selectedImage}
|
||||
imagePreviewUrl={imagePreviewUrl}
|
||||
onImageUpload={handleImageUpload}
|
||||
onRemoveImage={() => {
|
||||
setSelectedImage(null);
|
||||
setImagePreviewUrl(null);
|
||||
}}
|
||||
embeddingInput={embeddingInput}
|
||||
setEmbeddingInput={setEmbeddingInput}
|
||||
encodingFormat={encodingFormat}
|
||||
setEncodingFormat={setEncodingFormat}
|
||||
imageCount={imageCount}
|
||||
setImageCount={setImageCount}
|
||||
imageSize={imageSize}
|
||||
setImageSize={setImageSize}
|
||||
imageQuality={imageQuality}
|
||||
setImageQuality={setImageQuality}
|
||||
imageStyle={imageStyle}
|
||||
setImageStyle={setImageStyle}
|
||||
imagePrompt={imagePrompt}
|
||||
setImagePrompt={setImagePrompt}
|
||||
speechVoice={speechVoice}
|
||||
setSpeechVoice={setSpeechVoice}
|
||||
speechFormat={speechFormat}
|
||||
setSpeechFormat={setSpeechFormat}
|
||||
speechSpeed={speechSpeed}
|
||||
setSpeechSpeed={setSpeechSpeed}
|
||||
speechInput={speechInput}
|
||||
setSpeechInput={setSpeechInput}
|
||||
audioTranscriptionPrompt={audioTranscriptionPrompt}
|
||||
setAudioTranscriptionPrompt={setAudioTranscriptionPrompt}
|
||||
audioTemperature={audioTemperature}
|
||||
setAudioTemperature={setAudioTemperature}
|
||||
audioLanguage={audioLanguage}
|
||||
setAudioLanguage={setAudioLanguage}
|
||||
audioResponseFormat={audioResponseFormat}
|
||||
setAudioResponseFormat={setAudioResponseFormat}
|
||||
isRecording={isRecording}
|
||||
onStartRecording={startRecording}
|
||||
onStopRecording={stopRecording}
|
||||
onAudioUpload={handleAudioUpload}
|
||||
recordedAudio={recordedAudio}
|
||||
recordingUrl={recordingUrl}
|
||||
onRemoveAudio={() => {
|
||||
setRecordedAudio(null);
|
||||
setRecordingUrl(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Test Button */}
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={!selectedModelId || testEndpointMutation.isPending}
|
||||
className='w-full'
|
||||
>
|
||||
{testEndpointMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Testing {API_ENDPOINTS[selectedEndpoint].name}...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className='mr-2 h-4 w-4' />
|
||||
Test {API_ENDPOINTS[selectedEndpoint].name}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Results */}
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<XCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<strong>Test Failed:</strong> {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{response && (
|
||||
<Alert>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<strong>Test Successful!</strong>{' '}
|
||||
{API_ENDPOINTS[selectedEndpoint].name} endpoint responded
|
||||
correctly.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ApiEndpointResponse response={response} />
|
||||
|
||||
{response && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Raw Response</Label>
|
||||
<details className='group'>
|
||||
<summary className='text-muted-foreground hover:text-foreground cursor-pointer text-sm'>
|
||||
<Info className='mr-1 inline h-4 w-4' />
|
||||
Show detailed response data
|
||||
</summary>
|
||||
<pre className='bg-muted mt-2 max-h-60 overflow-auto rounded-md p-4 text-xs'>
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
156
ui/components/api-endpoint-types.ts
Normal file
156
ui/components/api-endpoint-types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
FileText,
|
||||
Eye,
|
||||
List,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
'chat-completions': {
|
||||
name: 'Chat Completions',
|
||||
path: '/chat/completions',
|
||||
icon: FileText,
|
||||
description: 'Test conversational AI with chat completion requests',
|
||||
},
|
||||
'vision-chat': {
|
||||
name: 'Vision Chat (Image + Text)',
|
||||
path: '/chat/completions',
|
||||
icon: Eye,
|
||||
description: 'Analyze images with text prompts using vision models',
|
||||
},
|
||||
embeddings: {
|
||||
name: 'Embeddings',
|
||||
path: '/embeddings',
|
||||
icon: List,
|
||||
description: 'Generate embeddings for text input',
|
||||
},
|
||||
images: {
|
||||
name: 'Image Generation',
|
||||
path: '/images/generations',
|
||||
icon: ImageIcon,
|
||||
description: 'Generate images from text prompts',
|
||||
},
|
||||
'audio-speech': {
|
||||
name: 'Text-to-Speech',
|
||||
path: '/audio/speech',
|
||||
icon: Mic,
|
||||
description: 'Convert text to speech audio',
|
||||
},
|
||||
'audio-transcription': {
|
||||
name: 'Audio Transcription',
|
||||
path: '/audio/transcriptions',
|
||||
icon: Volume2,
|
||||
description: 'Transcribe audio files to text',
|
||||
},
|
||||
models: {
|
||||
name: 'List Models',
|
||||
path: '/models',
|
||||
icon: List,
|
||||
description: 'List all available models from the provider',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type EndpointType = keyof typeof API_ENDPOINTS;
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string;
|
||||
messages: {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content:
|
||||
| string
|
||||
| Array<{
|
||||
type: 'text' | 'image_url';
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
detail?: 'low' | 'high' | 'auto';
|
||||
};
|
||||
}>;
|
||||
}[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface EmbeddingRequest {
|
||||
model: string;
|
||||
input: string | string[];
|
||||
encoding_format?: 'float' | 'base64';
|
||||
}
|
||||
|
||||
export interface ImageGenerationRequest {
|
||||
model?: string;
|
||||
prompt: string;
|
||||
n?: number;
|
||||
size?: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
|
||||
quality?: 'standard' | 'hd';
|
||||
style?: 'vivid' | 'natural';
|
||||
}
|
||||
|
||||
export interface AudioSpeechRequest {
|
||||
model: string;
|
||||
input: string;
|
||||
voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
|
||||
response_format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionRequest {
|
||||
model: string;
|
||||
file: File;
|
||||
prompt?: string;
|
||||
response_format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
|
||||
temperature?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_REQUESTS = {
|
||||
'chat-completions': {
|
||||
systemMessage: 'You are a helpful assistant. Please respond concisely.',
|
||||
userMessage:
|
||||
'Hello! Can you tell me what model you are and confirm that you are working correctly?',
|
||||
maxTokens: 150,
|
||||
temperature: 0.7,
|
||||
},
|
||||
'vision-chat': {
|
||||
systemMessage:
|
||||
'You are a helpful assistant that can analyze images. Please describe what you see.',
|
||||
userMessage:
|
||||
'What do you see in this image? Please provide a detailed description.',
|
||||
maxTokens: 300,
|
||||
temperature: 0.7,
|
||||
imageDetail: 'auto' as const,
|
||||
},
|
||||
embeddings: {
|
||||
input: 'The quick brown fox jumps over the lazy dog.',
|
||||
encoding_format: 'float' as const,
|
||||
},
|
||||
images: {
|
||||
prompt: 'A beautiful sunset over a mountain landscape',
|
||||
n: 1,
|
||||
size: '1024x1024' as const,
|
||||
quality: 'standard' as const,
|
||||
style: 'vivid' as const,
|
||||
},
|
||||
'audio-speech': {
|
||||
input: 'Hello, this is a test of the text-to-speech functionality.',
|
||||
voice: 'alloy' as const,
|
||||
response_format: 'mp3' as const,
|
||||
speed: 1.0,
|
||||
},
|
||||
'audio-transcription': {
|
||||
prompt: 'This is a test transcription.',
|
||||
response_format: 'json' as const,
|
||||
temperature: 0.0,
|
||||
language: '',
|
||||
},
|
||||
};
|
||||
|
||||
export type EndpointRequestData =
|
||||
| ChatCompletionRequest
|
||||
| EmbeddingRequest
|
||||
| ImageGenerationRequest
|
||||
| AudioSpeechRequest
|
||||
| AudioTranscriptionRequest
|
||||
| null;
|
||||
351
ui/components/app-page-shell.tsx
Normal file
351
ui/components/app-page-shell.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
DatabaseIcon,
|
||||
FileTextIcon,
|
||||
LayoutDashboardIcon,
|
||||
LogOutIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
WalletIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
import { adminLogout } from '@/lib/api/services/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyToggle } from '@/components/currency-toggle';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AppPageShellProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
|
||||
{ title: 'Balances', url: '/balances', icon: WalletIcon },
|
||||
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
|
||||
{ title: 'Models', url: '/models', icon: DatabaseIcon },
|
||||
{ title: 'Providers', url: '/providers', icon: ServerIcon },
|
||||
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
|
||||
] as const;
|
||||
|
||||
function isActivePath(pathname: string, itemUrl: string): boolean {
|
||||
if (itemUrl === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
|
||||
return pathname === itemUrl || pathname.startsWith(`${itemUrl}/`);
|
||||
}
|
||||
|
||||
export function AppPageShell({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: AppPageShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
try {
|
||||
await adminLogout();
|
||||
toast.success('Logged out successfully');
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
toast.error('Failed to logout');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-background text-foreground min-h-dvh overflow-x-clip md:h-screen md:overflow-hidden'>
|
||||
<div className='flex min-h-dvh w-full min-w-0 overflow-x-clip md:h-full'>
|
||||
<aside
|
||||
className={cn(
|
||||
'border-border/60 hidden shrink-0 border-r py-5 transition-[width,padding] duration-300 ease-in-out md:flex md:h-full md:flex-col md:overflow-y-auto',
|
||||
isSidebarCollapsed ? 'w-16 px-2' : 'w-60 px-4'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'px-1 transition-[padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed && 'px-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='shrink-0 rounded-sm'
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden transition-[max-width,opacity,transform] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'max-w-0 -translate-x-1 opacity-0'
|
||||
: 'max-w-[11rem] translate-x-0 opacity-100'
|
||||
)}
|
||||
>
|
||||
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
|
||||
Routstr Node
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground h-8 w-8 shrink-0 transition-transform duration-300 ease-in-out',
|
||||
isSidebarCollapsed ? 'mx-auto' : '-mr-1 ml-auto'
|
||||
)}
|
||||
onClick={() => setIsSidebarCollapsed((current) => !current)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelLeftOpenIcon className='h-4 w-4' />
|
||||
) : (
|
||||
<PanelLeftCloseIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='sr-only'>
|
||||
{isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'mt-5',
|
||||
isSidebarCollapsed
|
||||
? 'flex flex-col items-center space-y-2'
|
||||
: 'space-y-1.5'
|
||||
)}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActivePath(pathname, item.url);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.url}
|
||||
asChild
|
||||
variant={active ? 'outline' : 'ghost'}
|
||||
className={cn(
|
||||
'h-10 rounded-lg transition-[width,padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'mx-auto w-10 justify-center px-0'
|
||||
: 'w-full justify-start'
|
||||
)}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon className='h-4 w-4 shrink-0' />
|
||||
<span
|
||||
className={cn(
|
||||
'overflow-hidden whitespace-nowrap transition-[max-width,opacity,margin] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'ml-0 max-w-0 opacity-0'
|
||||
: 'ml-0.5 max-w-[9rem] opacity-100'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'mt-auto pt-3 transition-[padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'flex flex-col items-center space-y-1.5'
|
||||
: 'space-y-2'
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<>
|
||||
<CurrencyToggle
|
||||
compact
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='mx-auto'
|
||||
/>
|
||||
<ThemeToggle
|
||||
compact
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='mx-auto'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleLogout}
|
||||
className='text-muted-foreground hover:text-foreground mx-auto h-8 w-10 justify-center px-0'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Logout</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className='border-border/60 bg-card/30 space-y-1 rounded-lg border p-1'>
|
||||
<CurrencyToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<ThemeToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleLogout}
|
||||
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-8 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className='relative flex w-full min-w-0 flex-1 flex-col overflow-x-clip md:h-full md:min-h-0'>
|
||||
<div className='bg-background/80 supports-[backdrop-filter]:bg-background/72 sticky top-0 z-30 flex items-center gap-2 px-3 py-2 backdrop-blur-xl md:hidden'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 gap-2 rounded-lg px-3'
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
<PanelLeftOpenIcon className='h-4 w-4' />
|
||||
Menu
|
||||
</Button>
|
||||
</div>
|
||||
<main
|
||||
className={cn(
|
||||
'w-full min-w-0 flex-1 overflow-x-clip p-3 pb-4 sm:p-4 md:min-h-0 md:overflow-y-auto md:p-6 md:pb-6',
|
||||
contentClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
||||
<SheetContent
|
||||
side='left'
|
||||
showCloseButton={false}
|
||||
className='w-[min(88vw,18rem)] p-0 md:hidden'
|
||||
>
|
||||
<SheetTitle className='sr-only'>Navigation sidebar</SheetTitle>
|
||||
<SheetDescription className='sr-only'>
|
||||
Browse admin pages and access sidebar controls.
|
||||
</SheetDescription>
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border/60 px-4 pt-4 pb-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded-sm'
|
||||
/>
|
||||
<p className='truncate text-base font-medium tracking-tight'>
|
||||
Routstr Node
|
||||
</p>
|
||||
</div>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-muted-foreground hover:text-foreground h-8 w-8 shrink-0'
|
||||
>
|
||||
<PanelLeftCloseIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Close sidebar</span>
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-full min-h-0 flex-col px-3 pb-3'>
|
||||
<nav className='space-y-1.5 py-3'>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActivePath(pathname, item.url);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={`mobile-sidebar-${item.url}`}
|
||||
asChild
|
||||
variant={active ? 'outline' : 'ghost'}
|
||||
className='h-10 w-full justify-start rounded-lg'
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
>
|
||||
<Icon className='h-4 w-4' />
|
||||
{item.title}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className='border-border/60 bg-card/30 mt-auto space-y-1 rounded-lg border p-1'>
|
||||
<CurrencyToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<ThemeToggle
|
||||
menuSide='right'
|
||||
menuAlign='start'
|
||||
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={async () => {
|
||||
setIsMobileSidebarOpen(false);
|
||||
await handleLogout();
|
||||
}}
|
||||
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-9 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
|
||||
>
|
||||
<LogOutIcon className='h-4 w-4' />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
DatabaseIcon,
|
||||
LayoutDashboardIcon,
|
||||
@@ -10,15 +11,18 @@ import {
|
||||
WalletIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { NavSecondary } from '@/components/nav-secondary';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
const data = {
|
||||
@@ -48,7 +52,7 @@ const data = {
|
||||
},
|
||||
{
|
||||
title: 'Models',
|
||||
url: '/model',
|
||||
url: '/models',
|
||||
icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
@@ -68,29 +72,61 @@ const data = {
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar collapsible='offcanvas' {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarHeader className='px-3 pt-4 pb-3'>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className='data-[slot=sidebar-menu-button]:!p-1.5'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded'
|
||||
/>
|
||||
<span className='text-base font-semibold'>Routstr Node</span>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
width={24}
|
||||
height={24}
|
||||
className='rounded'
|
||||
/>
|
||||
<div className='space-y-0.5'>
|
||||
<p className='text-sm font-semibold tracking-tight'>
|
||||
Routstr Node
|
||||
</p>
|
||||
<p className='text-muted-foreground text-[11px]'>
|
||||
Admin dashboard
|
||||
</p>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className='flex-1 overflow-y-auto'>
|
||||
<NavSecondary items={data.navSecondary} className='mt-auto' />
|
||||
<SidebarContent className='flex-1 overflow-y-auto px-2 pb-2'>
|
||||
<NavSecondary items={data.navSecondary} />
|
||||
<SidebarGroup className='mt-auto px-0 pt-2 pb-0'>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
|
||||
<Link
|
||||
href='https://docs.routstr.com'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<span>Docs</span>
|
||||
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
|
||||
<Link
|
||||
href='https://chat.routstr.com'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<span>Chat App</span>
|
||||
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
{/*
|
||||
<SidebarFooter>
|
||||
|
||||
38
ui/components/auth-page-shell.tsx
Normal file
38
ui/components/auth-page-shell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface AuthPageShellProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthPageShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: AuthPageShellProps) {
|
||||
return (
|
||||
<div className='bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10 sm:py-12'>
|
||||
<Card className='w-full max-w-md'>
|
||||
<CardHeader className='space-y-1 pb-4'>
|
||||
<CardTitle className='text-center text-2xl font-bold'>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-center'>
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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,6 +7,7 @@ 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';
|
||||
import { WalletBalanceStats } from './wallet-balance-stats';
|
||||
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
|
||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||
|
||||
@@ -65,10 +66,6 @@ async function fetchWalletInfo(
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -175,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'>
|
||||
@@ -226,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,6 +9,7 @@ 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';
|
||||
|
||||
export type RefundReceipt = {
|
||||
@@ -74,10 +75,6 @@ async function fetchWalletInfo(
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -308,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>
|
||||
@@ -362,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'>
|
||||
@@ -401,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'}>
|
||||
@@ -482,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,13 +2,15 @@
|
||||
|
||||
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 {
|
||||
@@ -165,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'>
|
||||
@@ -173,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>
|
||||
@@ -191,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>
|
||||
@@ -212,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'>
|
||||
@@ -231,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'>
|
||||
@@ -239,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'>
|
||||
@@ -248,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'>
|
||||
@@ -258,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'>
|
||||
@@ -276,7 +310,7 @@ 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'>
|
||||
@@ -304,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>
|
||||
@@ -329,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'
|
||||
@@ -398,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'
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
History,
|
||||
Users,
|
||||
RotateCcw,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { Copy, RefreshCcw, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Card,
|
||||
@@ -154,10 +146,7 @@ export function KeyInfoDetails({
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<KeyRound className='text-primary h-5 w-5' />
|
||||
Key Information
|
||||
</CardTitle>
|
||||
<CardTitle className='text-xl'>Key Information</CardTitle>
|
||||
<CardDescription>
|
||||
Enter an API key to view its balance, consumption, and child keys.
|
||||
</CardDescription>
|
||||
@@ -202,10 +191,7 @@ export function KeyInfoDetails({
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<ShieldCheck className='text-primary h-5 w-5' />
|
||||
Status & Identity
|
||||
</CardTitle>
|
||||
<CardTitle className='text-lg'>Status & Identity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -216,7 +202,7 @@ export function KeyInfoDetails({
|
||||
</div>
|
||||
{walletInfo.parentKey && (
|
||||
<div className='space-y-1'>
|
||||
<span className='text-muted-foreground text-xs tracking-wider uppercase'>
|
||||
<span className='text-muted-foreground text-xs tracking-wider'>
|
||||
Parent Key
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -255,10 +241,7 @@ export function KeyInfoDetails({
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<History className='text-primary h-5 w-5' />
|
||||
Consumption
|
||||
</CardTitle>
|
||||
<CardTitle className='text-lg'>Consumption</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -313,8 +296,7 @@ export function KeyInfoDetails({
|
||||
walletInfo.childKeys.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||
<Users className='text-primary h-5 w-5' />
|
||||
<CardTitle className='text-lg'>
|
||||
Child Keys ({walletInfo.childKeys.length})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -359,7 +341,7 @@ export function KeyInfoDetails({
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4 text-xs sm:grid-cols-5'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Requests
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
@@ -367,7 +349,7 @@ export function KeyInfoDetails({
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Spent
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
@@ -375,7 +357,7 @@ export function KeyInfoDetails({
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Limit
|
||||
</p>
|
||||
<p className='font-mono font-medium'>
|
||||
@@ -385,7 +367,7 @@ export function KeyInfoDetails({
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Policy
|
||||
</p>
|
||||
<p className='font-medium capitalize'>
|
||||
@@ -393,7 +375,7 @@ export function KeyInfoDetails({
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
|
||||
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
|
||||
Expires
|
||||
</p>
|
||||
<p className='font-medium'>
|
||||
|
||||
@@ -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,6 +10,7 @@ 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';
|
||||
import type { WalletSnapshot } from './key-info-details';
|
||||
|
||||
@@ -35,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));
|
||||
}
|
||||
@@ -56,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,
|
||||
@@ -388,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>
|
||||
@@ -434,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>
|
||||
@@ -531,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>
|
||||
@@ -567,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>
|
||||
)}
|
||||
@@ -662,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>
|
||||
@@ -693,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>
|
||||
);
|
||||
}
|
||||
349
ui/components/provider-balance.tsx
Normal file
349
ui/components/provider-balance.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'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;
|
||||
isRoutstr?: boolean;
|
||||
nodeUrl?: string;
|
||||
}
|
||||
|
||||
export function ProviderBalance({
|
||||
providerId,
|
||||
platformUrl,
|
||||
isRoutstr = false,
|
||||
nodeUrl,
|
||||
}: 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 = Number(topupAmount);
|
||||
|
||||
if (Number.isNaN(amount)) {
|
||||
setTopupError(
|
||||
isRoutstr
|
||||
? 'Please enter a valid amount in sats'
|
||||
: 'Please enter a valid amount'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRoutstr) {
|
||||
if (!Number.isInteger(amount) || amount < 1) {
|
||||
setTopupError('Amount must be a whole number of sats');
|
||||
return;
|
||||
}
|
||||
} else 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 = isRoutstr
|
||||
? `${balance.toLocaleString()} sats`
|
||||
: `$${balance.toFixed(2)}`;
|
||||
} else if (balance && typeof balance === 'object') {
|
||||
const b = balance as Record<string, unknown>;
|
||||
if (typeof b.balance === 'number') {
|
||||
displayValue = isRoutstr
|
||||
? `${b.balance.toLocaleString()} sats`
|
||||
: `$${b.balance.toFixed(2)}`;
|
||||
} else if (typeof b.balance === 'string') {
|
||||
displayValue = b.balance;
|
||||
} else if (b.amount !== undefined) {
|
||||
const amount = Number(b.amount);
|
||||
if (!Number.isNaN(amount)) {
|
||||
displayValue = isRoutstr
|
||||
? `${amount.toLocaleString()} sats`
|
||||
: `$${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.'
|
||||
: isRoutstr
|
||||
? `Top up your balance on node ${nodeUrl || ''}`.trim()
|
||||
: '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'>
|
||||
{isRoutstr ? 'Amount (sats)' : 'Amount (USD)'}
|
||||
</Label>
|
||||
<Input
|
||||
id='topup_amount'
|
||||
type='number'
|
||||
placeholder={
|
||||
isRoutstr ? 'Enter amount in sats' : 'Enter amount (1-500)'
|
||||
}
|
||||
value={topupAmount}
|
||||
onChange={(e) => {
|
||||
setTopupAmount(e.target.value);
|
||||
setTopupError('');
|
||||
}}
|
||||
min='1'
|
||||
max={isRoutstr ? undefined : '500'}
|
||||
step={isRoutstr ? '1' : '0.01'}
|
||||
/>
|
||||
{isRoutstr && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
The invoice amount will be created in sats.
|
||||
</p>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
292
ui/components/provider-card.tsx
Normal file
292
ui/components/provider-card.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
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,
|
||||
Key,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { ProviderBalance } from '@/components/provider-balance';
|
||||
import { ProviderModelsPanel } from '@/components/provider-models-panel';
|
||||
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
||||
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
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;
|
||||
onUpdateApiKey: (newKey: string) => void;
|
||||
availableMints: string[];
|
||||
}
|
||||
|
||||
export function ProviderCard({
|
||||
provider,
|
||||
isExpanded,
|
||||
canShowBalance,
|
||||
platformUrl,
|
||||
isModelsLoading,
|
||||
providerModels,
|
||||
isDeletingModel,
|
||||
onToggleExpansion,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
onBatchOverride,
|
||||
onAddModel,
|
||||
onEditModel,
|
||||
onDeleteModel,
|
||||
onOverrideModel,
|
||||
onUpdateApiKey,
|
||||
}: ProviderCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
||||
const hasDetails = Boolean(provider.api_version) || isExpanded;
|
||||
const isRoutstr = provider.provider_type === 'routstr';
|
||||
|
||||
const refundMutation = useMutation({
|
||||
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
||||
onSuccess: (data) => {
|
||||
if (data.ok) {
|
||||
toast.success('Refund successful', { description: data.message });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', provider.id],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['balances'] });
|
||||
} else {
|
||||
toast.error('Refund failed', { description: data.message });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Refund error: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
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={cn(
|
||||
'col-span-2 sm:col-auto',
|
||||
provider.provider_type === 'routstr' && 'col-span-1'
|
||||
)}
|
||||
>
|
||||
<ProviderBalance
|
||||
providerId={provider.id}
|
||||
platformUrl={platformUrl}
|
||||
isRoutstr={provider.provider_type === 'routstr'}
|
||||
nodeUrl={provider.base_url}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRoutstr && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsKeyModalOpen(true)}
|
||||
className={cn(
|
||||
'justify-center gap-1.5',
|
||||
canShowBalance && provider.api_key
|
||||
? 'col-span-1'
|
||||
: 'col-span-2 sm:col-auto'
|
||||
)}
|
||||
>
|
||||
<Key className='h-4 w-4' />
|
||||
<span>New Key</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isRoutstr && provider.api_key && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => refundMutation.mutate()}
|
||||
disabled={refundMutation.isPending}
|
||||
className='justify-center gap-1.5 text-orange-600 hover:text-orange-700 dark:text-orange-400'
|
||||
title='Refund balance to local wallet'
|
||||
>
|
||||
<RotateCcw
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
refundMutation.isPending && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<span>Refund</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{provider.api_key
|
||||
? 'Create New Key on Upstream Node'
|
||||
: 'Create API Key'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{provider.api_key
|
||||
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
|
||||
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='py-4'>
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={provider.base_url || ''}
|
||||
onApiKeyCreated={async (newApiKey) => {
|
||||
if (provider.api_key) {
|
||||
try {
|
||||
const result = await RoutstrProviderService.refundBalance(
|
||||
provider.id
|
||||
);
|
||||
if (result.ok) {
|
||||
toast.success('Old key refunded', {
|
||||
description: result.message,
|
||||
});
|
||||
} else {
|
||||
toast.warning('Refund skipped', {
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.warning(
|
||||
`Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
onUpdateApiKey(newApiKey);
|
||||
setIsKeyModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
91
ui/components/provider-form-dialog-content.tsx
Normal file
91
ui/components/provider-form-dialog-content.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
availableMints: string[];
|
||||
}
|
||||
|
||||
export function ProviderFormDialogContent({
|
||||
mode,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
formData,
|
||||
setFormData,
|
||||
providerTypes,
|
||||
providerFeePlaceholder,
|
||||
docsLinkClassName,
|
||||
canCreateAccount,
|
||||
isCreatingAccount,
|
||||
onCreateAccount,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
availableMints,
|
||||
}: 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}
|
||||
availableMints={availableMints}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
241
ui/components/provider-form-fields.tsx
Normal file
241
ui/components/provider-form-fields.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'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';
|
||||
import { RoutstrNodeSettings } from '@/components/providers/RoutstrNodeSettings';
|
||||
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
||||
|
||||
interface ProviderFormFieldsProps {
|
||||
mode: 'create' | 'edit';
|
||||
formData: CreateUpstreamProvider;
|
||||
setFormData: Dispatch<SetStateAction<CreateUpstreamProvider>>;
|
||||
providerTypes: ProviderType[];
|
||||
providerFeePlaceholder: string;
|
||||
docsLinkClassName: string;
|
||||
canCreateAccount: boolean;
|
||||
isCreatingAccount: boolean;
|
||||
onCreateAccount: () => void;
|
||||
availableMints: string[];
|
||||
}
|
||||
|
||||
export function ProviderFormFields({
|
||||
mode,
|
||||
formData,
|
||||
setFormData,
|
||||
providerTypes,
|
||||
providerFeePlaceholder,
|
||||
docsLinkClassName,
|
||||
canCreateAccount,
|
||||
isCreatingAccount,
|
||||
onCreateAccount,
|
||||
availableMints,
|
||||
}: 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 getDefaultBaseUrl = (type: string) => {
|
||||
const selectedType = providerTypes.find((pt) => pt.id === type);
|
||||
return selectedType?.default_base_url || '';
|
||||
};
|
||||
|
||||
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) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider_type: value,
|
||||
base_url: getDefaultBaseUrl(value),
|
||||
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>
|
||||
|
||||
{formData.provider_type === 'routstr' && (
|
||||
<RoutstrNodeSettings
|
||||
settings={formData.provider_settings || {}}
|
||||
onSettingsChange={(settings) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider_settings: settings,
|
||||
}))
|
||||
}
|
||||
availableMints={availableMints}
|
||||
idPrefix={mode === 'edit' ? 'edit' : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{formData.provider_type !== 'routstr' && (
|
||||
<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>
|
||||
|
||||
{mode === 'create' && formData.provider_type === 'routstr' && (
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={formData.base_url || ''}
|
||||
onApiKeyCreated={(newApiKey) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
api_key: newApiKey,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
172
ui/components/providers/ProviderBalance.tsx
Normal file
172
ui/components/providers/ProviderBalance.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SimpleLightningTopup } from './SimpleLightningTopup';
|
||||
import { SimpleCashuTopup } from './SimpleCashuTopup';
|
||||
|
||||
interface ProviderBalanceProps {
|
||||
providerId: number;
|
||||
platformUrl?: string | null;
|
||||
isRoutstr?: boolean;
|
||||
nodeUrl?: string;
|
||||
}
|
||||
|
||||
export function ProviderBalance({
|
||||
providerId,
|
||||
platformUrl,
|
||||
isRoutstr = false,
|
||||
nodeUrl,
|
||||
}: ProviderBalanceProps) {
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: balanceData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
queryFn: () => AdminService.getProviderBalance(providerId),
|
||||
refetchInterval: 30000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const handleTopUpClick = () => {
|
||||
if (
|
||||
platformUrl &&
|
||||
(platformUrl.includes('openrouter.ai') ||
|
||||
platformUrl.includes('openai.com'))
|
||||
) {
|
||||
window.open(platformUrl, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTopupDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsTopupDialogOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
};
|
||||
|
||||
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 = isRoutstr
|
||||
? `${balance.toLocaleString()} sats`
|
||||
: `$${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 font-mono sm:w-auto'
|
||||
>
|
||||
{isHovered ? 'Top Up' : displayValue}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Top Up Balance</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isRoutstr
|
||||
? `Top up your balance on node ${nodeUrl}`
|
||||
: 'Choose a payment method to top up your account balance.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-6 py-4'>
|
||||
<section className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
Lightning Top-up
|
||||
</Label>
|
||||
<SimpleLightningTopup
|
||||
providerId={providerId}
|
||||
baseUrl={nodeUrl || ''}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
Cashu Token Top-up
|
||||
</Label>
|
||||
<SimpleCashuTopup
|
||||
providerId={providerId}
|
||||
baseUrl={nodeUrl || ''}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCloseDialog}
|
||||
className='w-full'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
295
ui/components/providers/RoutstrCreateKeySection.tsx
Normal file
295
ui/components/providers/RoutstrCreateKeySection.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface RoutstrCreateKeySectionProps {
|
||||
baseUrl: string;
|
||||
onApiKeyCreated: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
async function generateQR(text: string): Promise<string> {
|
||||
try {
|
||||
return await QRCode.toDataURL(text, {
|
||||
type: 'image/png',
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: { dark: '#000000', light: '#FFFFFF' },
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function RoutstrCreateKeySection({
|
||||
baseUrl,
|
||||
onApiKeyCreated,
|
||||
}: RoutstrCreateKeySectionProps) {
|
||||
// Lightning state
|
||||
const [lnAmount, setLnAmount] = useState('');
|
||||
const [lnInvoice, setLnInvoice] = useState<{
|
||||
bolt11: string;
|
||||
invoice_id: string;
|
||||
} | null>(null);
|
||||
const [lnQrCode, setLnQrCode] = useState('');
|
||||
const [isCreatingLn, setIsCreatingLn] = useState(false);
|
||||
const [isWaitingLn, setIsWaitingLn] = useState(false);
|
||||
|
||||
// Cashu state
|
||||
const [cashuToken, setCashuToken] = useState('');
|
||||
const [isCreatingCashu, setIsCreatingCashu] = useState(false);
|
||||
|
||||
if (!baseUrl) {
|
||||
return (
|
||||
<div className='bg-muted/30 rounded-lg border p-4'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Enter the upstream node Base URL above to enable key creation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const pollInvoiceStatus = (invoiceId: string) => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${cleanUrl}/v1/balance/lightning/invoice/${invoiceId}/status`
|
||||
);
|
||||
if (!resp.ok) throw new Error('Failed to check status');
|
||||
|
||||
const status = await resp.json();
|
||||
|
||||
if (status.status === 'paid' && status.api_key) {
|
||||
onApiKeyCreated(status.api_key);
|
||||
setLnInvoice(null);
|
||||
setLnQrCode('');
|
||||
setIsWaitingLn(false);
|
||||
setLnAmount('');
|
||||
toast.success('Payment received! API key created.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'expired' || status.status === 'cancelled') {
|
||||
toast.error('Invoice expired or cancelled');
|
||||
setIsWaitingLn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
toast.error('Payment timeout');
|
||||
setIsWaitingLn(false);
|
||||
}
|
||||
} catch {
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
setIsWaitingLn(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const handleCreateLightning = async () => {
|
||||
const amount = parseInt(lnAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
toast.error('Enter a valid amount in sats');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingLn(true);
|
||||
try {
|
||||
const resp = await fetch(`${cleanUrl}/v1/balance/lightning/invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount_sats: amount, purpose: 'create' }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorText = await resp.text();
|
||||
throw new Error(errorText || 'Failed to create invoice');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
setLnInvoice({ bolt11: data.bolt11, invoice_id: data.invoice_id });
|
||||
|
||||
const qr = await generateQR(data.bolt11);
|
||||
setLnQrCode(qr);
|
||||
setIsWaitingLn(true);
|
||||
|
||||
pollInvoiceStatus(data.invoice_id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create invoice');
|
||||
} finally {
|
||||
setIsCreatingLn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCashu = async () => {
|
||||
if (!cashuToken.trim()) {
|
||||
toast.error('Paste a Cashu token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingCashu(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
initial_balance_token: cashuToken.trim(),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${cleanUrl}/v1/balance/create?${params.toString()}`,
|
||||
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorText = await resp.text();
|
||||
throw new Error(errorText || 'Failed to create API key');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
onApiKeyCreated(data.api_key);
|
||||
setCashuToken('');
|
||||
toast.success('API key created');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create key');
|
||||
} finally {
|
||||
setIsCreatingCashu(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-muted/30 space-y-4 rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-sm font-semibold'>Create API Key</Label>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
External Node
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Create an API key on the upstream Routstr node by paying with Lightning
|
||||
or Cashu.
|
||||
</p>
|
||||
|
||||
<Tabs defaultValue='lightning' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='lightning' className='gap-1 text-xs'>
|
||||
<Zap className='h-3 w-3' />
|
||||
Lightning
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='cashu' className='gap-1 text-xs'>
|
||||
<KeyRound className='h-3 w-3' />
|
||||
Cashu
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='lightning' className='mt-3 space-y-3'>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='Amount in sats'
|
||||
value={lnAmount}
|
||||
onChange={(e) => setLnAmount(e.target.value)}
|
||||
className='h-9'
|
||||
disabled={isWaitingLn}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateLightning}
|
||||
disabled={isCreatingLn || isWaitingLn}
|
||||
size='sm'
|
||||
>
|
||||
{isCreatingLn ? 'Creating...' : 'Get Invoice'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lnInvoice && (
|
||||
<div className='space-y-2 border-t pt-2'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-xs'>
|
||||
<span>Pay this invoice to create your key</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6'
|
||||
onClick={() => handleCopy(lnInvoice.bolt11)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
{lnQrCode && (
|
||||
<div className='flex justify-center py-2'>
|
||||
<Image
|
||||
src={lnQrCode}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-48 w-48'
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='bg-muted rounded border p-2 font-mono text-[10px] break-all'>
|
||||
{lnInvoice.bolt11}
|
||||
</div>
|
||||
{isWaitingLn && (
|
||||
<div className='flex animate-pulse items-center gap-2 text-xs text-orange-600'>
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
Waiting for payment...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='cashu' className='mt-3 space-y-3'>
|
||||
<Textarea
|
||||
placeholder='Paste Cashu token (cashuA1...)'
|
||||
value={cashuToken}
|
||||
onChange={(e) => setCashuToken(e.target.value)}
|
||||
rows={3}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateCashu}
|
||||
disabled={isCreatingCashu}
|
||||
size='sm'
|
||||
className='w-full'
|
||||
>
|
||||
{isCreatingCashu ? 'Creating...' : 'Create API Key'}
|
||||
</Button>
|
||||
<p className='text-muted-foreground text-[10px]'>
|
||||
Redeems the token instantly and returns an API key.
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
ui/components/providers/RoutstrNodeSettings.tsx
Normal file
135
ui/components/providers/RoutstrNodeSettings.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
interface ProviderSettings {
|
||||
topup_mint_url?: string;
|
||||
auto_topup?: boolean;
|
||||
topup_threshold?: number;
|
||||
topup_amount_limit?: number;
|
||||
refund_on_expiry?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RoutstrNodeSettingsProps {
|
||||
settings: ProviderSettings;
|
||||
onSettingsChange: (settings: ProviderSettings) => void;
|
||||
availableMints: string[];
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export function RoutstrNodeSettings({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
availableMints,
|
||||
idPrefix = '',
|
||||
}: RoutstrNodeSettingsProps) {
|
||||
const prefix = idPrefix ? `${idPrefix}_` : '';
|
||||
|
||||
const update = (patch: Partial<ProviderSettings>) => {
|
||||
onSettingsChange({ ...settings, ...patch });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-muted/30 grid gap-4 rounded-lg border p-4'>
|
||||
<Label className='text-sm font-semibold'>Routstr Node Settings</Label>
|
||||
|
||||
<div className='grid gap-3'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor={`${prefix}topup_mint_url`} className='text-xs'>
|
||||
Top-up Mint
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.topup_mint_url || ''}
|
||||
onValueChange={(value) => update({ topup_mint_url: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`${prefix}topup_mint_url`}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
<SelectValue placeholder='Select a mint from your node configuration' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableMints.length > 0 ? (
|
||||
availableMints.map((mint) => (
|
||||
<SelectItem key={mint} value={mint} className='text-xs'>
|
||||
{mint}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value='none' disabled className='text-xs'>
|
||||
No mints configured in global settings
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-[10px]'>
|
||||
The token for top-up will be created from this mint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor={`${prefix}auto_topup`} className='text-sm'>
|
||||
Enable Auto Top-up
|
||||
</Label>
|
||||
<Switch
|
||||
id={`${prefix}auto_topup`}
|
||||
checked={!!settings.auto_topup}
|
||||
onCheckedChange={(checked) => update({ auto_topup: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.auto_topup && (
|
||||
<div className='border-primary/20 grid gap-4 border-l-2 pt-2 pl-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label
|
||||
htmlFor={`${prefix}topup_threshold`}
|
||||
className='text-xs font-medium'
|
||||
>
|
||||
When credits are below (Sats)
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}topup_threshold`}
|
||||
type='number'
|
||||
className='h-9'
|
||||
placeholder='e.g. 1000'
|
||||
value={settings.topup_threshold || ''}
|
||||
onChange={(e) =>
|
||||
update({ topup_threshold: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label
|
||||
htmlFor={`${prefix}topup_amount_limit`}
|
||||
className='text-xs font-medium'
|
||||
>
|
||||
Purchase this amount (Sats)
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}topup_amount_limit`}
|
||||
type='number'
|
||||
className='h-9'
|
||||
placeholder='e.g. 5000'
|
||||
value={settings.topup_amount_limit || ''}
|
||||
onChange={(e) =>
|
||||
update({ topup_amount_limit: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
ui/components/providers/RoutstrProviderCard.tsx
Normal file
272
ui/components/providers/RoutstrProviderCard.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Database,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdminService,
|
||||
UpstreamProvider,
|
||||
UpdateUpstreamProvider,
|
||||
} from '@/lib/api/services/admin';
|
||||
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
||||
import { RoutstrCreateKeySection } from './RoutstrCreateKeySection';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RoutstrProviderCardProps {
|
||||
provider: UpstreamProvider;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdateKey?: () => void;
|
||||
balanceComponent: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RoutstrProviderCard({
|
||||
provider,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onEdit,
|
||||
onDelete,
|
||||
balanceComponent,
|
||||
children,
|
||||
}: RoutstrProviderCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isKeyDialogOpen, setIsKeyDialogOpen] = useState(false);
|
||||
|
||||
const hasMint = !!provider.provider_settings?.topup_mint_url;
|
||||
const hasApiKey = !!provider.api_key;
|
||||
|
||||
const refundMutation = useMutation({
|
||||
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
||||
onSuccess: (data) => {
|
||||
if (data.ok) {
|
||||
toast.success('Refund successful', {
|
||||
description: data.message,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', provider.id],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['balances'] });
|
||||
} else {
|
||||
toast.error('Refund failed', {
|
||||
description: data.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Refund error: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateKeyMutation = useMutation({
|
||||
mutationFn: (data: { id: number; data: UpdateUpstreamProvider }) =>
|
||||
AdminService.updateUpstreamProvider(data.id, data.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', provider.id],
|
||||
});
|
||||
setIsKeyDialogOpen(false);
|
||||
toast.success('API key saved to provider');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to save key: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyCreated = async (newApiKey: string) => {
|
||||
if (hasApiKey) {
|
||||
try {
|
||||
const result = await RoutstrProviderService.refundBalance(provider.id);
|
||||
if (result.ok) {
|
||||
toast.success('Old key refunded', {
|
||||
description: result.message,
|
||||
});
|
||||
} else {
|
||||
toast.warning('Refund skipped', {
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.warning(
|
||||
`Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
updateKeyMutation.mutate({
|
||||
id: provider.id,
|
||||
data: { api_key: newApiKey },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col gap-4 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 text-lg'>Routstr Node</CardTitle>
|
||||
<Badge
|
||||
variant={provider.enabled ? 'default' : 'secondary'}
|
||||
className='w-fit sm:ml-2'
|
||||
>
|
||||
{provider.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{!hasApiKey && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex items-center gap-1 border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400'
|
||||
>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
No API Key
|
||||
</Badge>
|
||||
)}
|
||||
{!hasMint && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex items-center gap-1 border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-400'
|
||||
title='Top-up is not possible because no top-up mint is selected in the provider settings. Please edit settings to select a mint from your node configuration.'
|
||||
>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
Top-up Disabled: No Mint Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className='mt-1 break-all'>
|
||||
{provider.base_url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{hasApiKey && (
|
||||
<div className='flex flex-col gap-1'>{balanceComponent}</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsKeyDialogOpen(true)}
|
||||
className='w-full sm:w-auto'
|
||||
title={
|
||||
hasApiKey
|
||||
? 'Create a new key on the upstream node'
|
||||
: 'Create an API key on the upstream node'
|
||||
}
|
||||
>
|
||||
<KeyRound className='mr-1 h-4 w-4' />
|
||||
<span className='hidden sm:inline'>
|
||||
{hasApiKey ? 'New Key' : 'Create Key'}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{hasApiKey && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => refundMutation.mutate()}
|
||||
disabled={refundMutation.isPending}
|
||||
className='text-orange-600 hover:text-orange-700 dark:text-orange-400'
|
||||
title='Refund balance to local wallet'
|
||||
>
|
||||
<RotateCcw
|
||||
className={`mr-1 h-4 w-4 ${refundMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<span className='hidden sm:inline'>Refund</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onToggleExpand}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Database className='mr-1 h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Models</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className='ml-1 h-4 w-4' />
|
||||
) : (
|
||||
<ChevronDown className='ml-1 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onEdit}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onDelete}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{children}
|
||||
</Card>
|
||||
|
||||
<Dialog open={isKeyDialogOpen} onOpenChange={setIsKeyDialogOpen}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasApiKey ? 'Create New Key on Upstream Node' : 'Create API Key'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasApiKey
|
||||
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
|
||||
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={provider.base_url}
|
||||
onApiKeyCreated={handleKeyCreated}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setIsKeyDialogOpen(false)}
|
||||
className='w-full'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user