mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a0473fb3 | ||
|
|
f1c3b515fe | ||
|
|
74ddc48ff5 | ||
|
|
6db11ed88f | ||
|
|
4d302baeb5 | ||
|
|
063df51ced | ||
|
|
e97fa60b27 | ||
|
|
42b3840df6 | ||
|
|
a0b2b9466c | ||
|
|
9a553de011 | ||
|
|
901a6a9ba2 | ||
|
|
691927a996 | ||
|
|
8fcecf2c1f | ||
|
|
aed967dc44 | ||
|
|
ed8102d533 | ||
|
|
8fa8475cca | ||
|
|
4723b9db4d | ||
|
|
358ff25899 | ||
|
|
7267bb87b9 | ||
|
|
173f5fbcbd | ||
|
|
9006709f8d | ||
|
|
7d48b36be8 | ||
|
|
8b3fdaa545 | ||
|
|
b43d57df23 | ||
|
|
b9a5a9276e | ||
|
|
a79fdf7212 | ||
|
|
9560050946 | ||
|
|
dd88e9b172 | ||
|
|
e31b45fa9e | ||
|
|
1ed8b29d64 | ||
|
|
59f8d31719 | ||
|
|
93a368b1a2 | ||
|
|
d3dd346853 | ||
|
|
0198569a9a | ||
|
|
7e648cb5c2 | ||
|
|
deb75624f3 | ||
|
|
c5cb562165 | ||
|
|
8a89a38864 | ||
|
|
09e7f1f0bf | ||
|
|
d9d082ad5c | ||
|
|
9cd4ff5c21 | ||
|
|
11eb20a2d1 | ||
|
|
a63e81db06 | ||
|
|
8fc1b6484c | ||
|
|
9bc3feff62 | ||
|
|
cb22968ff3 | ||
|
|
e3bca39815 | ||
|
|
958f28fd82 | ||
|
|
c8c30d7cfd | ||
|
|
2c2124952f | ||
|
|
f56ba92ae8 | ||
|
|
1fa71ee806 | ||
|
|
97d164e181 | ||
|
|
9f3c915dec | ||
|
|
4ff6218140 | ||
|
|
67ea6aec43 | ||
|
|
41f346417a | ||
|
|
8a373276ce | ||
|
|
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 | ||
|
|
e794b09614 | ||
|
|
350714f23a | ||
|
|
9cc2e84f7c | ||
|
|
342a7f7f16 | ||
|
|
7694f20006 | ||
|
|
3ea0a267dd | ||
|
|
68e537f6fb | ||
|
|
814c39898d | ||
|
|
fdd4f12f5c | ||
|
|
6957d8c0d9 | ||
|
|
2c358276bf | ||
|
|
c5fd386c1e | ||
|
|
ce9834d7ec | ||
|
|
6ebe73f2f7 | ||
|
|
98aecb08f9 |
@@ -14,6 +14,7 @@ UPSTREAM_API_KEY=your-upstream-api-key
|
||||
# HTTP_URL=https://api.mynode.com
|
||||
# ONION_URL=http://mynode.onion (auto fetched from compose)
|
||||
# RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com"
|
||||
# ENABLE_ANALYTICS_SHARING=true
|
||||
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me"
|
||||
# RECEIVE_LN_ADDRESS=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ Announce your node on the network:
|
||||
| **Npub** | Your Nostr public key |
|
||||
| **Nsec** | Your Nostr private key (for signing) |
|
||||
| **Relays** | Relays to publish announcements |
|
||||
| **Share Analytics** | Publish aggregate usage stats to Nostr |
|
||||
|
||||
See [Discovery](discovery.md) for details.
|
||||
|
||||
@@ -122,6 +123,7 @@ Use environment variables for:
|
||||
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
||||
| `NPUB` | Nostr public key (bech32) | — |
|
||||
| `NSEC` | Nostr private key | — |
|
||||
| `ENABLE_ANALYTICS_SHARING` | Enable usage analytics sharing to Nostr | `true` |
|
||||
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||
|
||||
@@ -152,6 +152,7 @@ Manage which mints you accept payments from:
|
||||
|-------|-------------|
|
||||
| **Nsec** | Private key for signing announcements |
|
||||
| **Relays** | Where to publish your node advertisement |
|
||||
| **Share Analytics** | Toggle publishing aggregate usage stats to Nostr |
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add cashu_transactions table
|
||||
|
||||
Revision ID: a776ca70e5fe
|
||||
Revises: 614c0a740e68
|
||||
Create Date: 2026-03-11 22:00:01.554762
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a776ca70e5fe"
|
||||
down_revision = "614c0a740e68"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cashu_transactions",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("amount", sa.Integer(), nullable=False),
|
||||
sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column(
|
||||
"type",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=False,
|
||||
server_default="out",
|
||||
),
|
||||
sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("collected", sa.Boolean(), nullable=False),
|
||||
sa.Column("swept", sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("cashu_transactions")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+112
-1
@@ -118,12 +118,32 @@ 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
|
||||
|
||||
# Group upstreams by URL and keep only the one with the lowest fee for each URL
|
||||
upstreams_by_url: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
for upstream in upstreams:
|
||||
url = getattr(upstream, "base_url", "")
|
||||
if url not in upstreams_by_url:
|
||||
upstreams_by_url[url] = []
|
||||
upstreams_by_url[url].append(upstream)
|
||||
|
||||
filtered_upstreams: list["BaseUpstreamProvider"] = []
|
||||
for providers in upstreams_by_url.values():
|
||||
best_provider = min(providers, key=lambda p: p.provider_fee)
|
||||
filtered_upstreams.append(best_provider)
|
||||
|
||||
# Separate OpenRouter from other providers
|
||||
openrouter: "BaseUpstreamProvider" | None = None
|
||||
other_upstreams: list["BaseUpstreamProvider"] = []
|
||||
|
||||
for upstream in upstreams:
|
||||
for upstream in filtered_upstreams:
|
||||
base_url = getattr(upstream, "base_url", "")
|
||||
if base_url == "https://openrouter.ai/api/v1":
|
||||
openrouter = upstream
|
||||
@@ -134,6 +154,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 +178,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 +220,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 +230,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"]] = {}
|
||||
|
||||
+43
-39
@@ -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",
|
||||
@@ -370,12 +372,12 @@ async def validate_bearer_key(
|
||||
},
|
||||
)
|
||||
|
||||
key_preview = bearer_key[:10] + "..." if len(bearer_key) > 10 else bearer_key
|
||||
logger.error(
|
||||
"Invalid API key format",
|
||||
f"Invalid API key format: preview={key_preview!r} length={len(bearer_key)} "
|
||||
f"(expected 'sk-...' or 'cashu...' token)",
|
||||
extra={
|
||||
"key_preview": bearer_key[:10] + "..."
|
||||
if len(bearer_key) > 10
|
||||
else bearer_key,
|
||||
"key_preview": key_preview,
|
||||
"key_length": len(bearer_key),
|
||||
},
|
||||
)
|
||||
@@ -384,7 +386,7 @@ async def validate_bearer_key(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Invalid API key",
|
||||
"message": "Invalid API key format. Expected an 'sk-...' API key or a 'cashu...' token.",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
@@ -588,12 +590,15 @@ async def pay_for_request(
|
||||
|
||||
async def revert_pay_for_request(
|
||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Revert a previously reserved payment. Returns True if revert succeeded,
|
||||
False if the reservation was already released (prevents negative reserved_balance)."""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
@@ -607,6 +612,7 @@ async def revert_pay_for_request(
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
@@ -616,8 +622,8 @@ async def revert_pay_for_request(
|
||||
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
logger.warning(
|
||||
"Revert skipped - reservation already released (no-op to prevent negative reserved_balance)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
@@ -625,19 +631,11 @@ async def revert_pay_for_request(
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
return False
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
return True
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
@@ -669,17 +667,19 @@ async def adjust_payment_for_tokens(
|
||||
release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
|
||||
)
|
||||
)
|
||||
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||
result = await session.exec(release_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also release on child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost
|
||||
@@ -688,14 +688,24 @@ async def adjust_payment_for_tokens(
|
||||
await session.exec(child_release_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
"Released reservation without charging (fallback)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
if result.rowcount == 0: # type: ignore[union-attr]
|
||||
logger.warning(
|
||||
"Release reservation skipped - already released (no-op to prevent negative reserved_balance)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Released reservation without charging (fallback)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to release reservation in fallback",
|
||||
@@ -766,6 +776,8 @@ async def adjust_payment_for_tokens(
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost.total_msats,
|
||||
"input_tokens": cost.input_tokens,
|
||||
"output_tokens": cost.output_tokens,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
@@ -789,6 +801,8 @@ async def adjust_payment_for_tokens(
|
||||
"cost_difference": cost_difference,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
"input_tokens": cost.input_tokens,
|
||||
"output_tokens": cost.output_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -997,18 +1011,8 @@ async def adjust_payment_for_tokens(
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback: should not reach here, but release reservation just in case
|
||||
logger.error(
|
||||
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
await release_reservation_only()
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": deducted_max_cost,
|
||||
}
|
||||
# All calculate_cost variants are handled above.
|
||||
raise AssertionError("Unreachable: unhandled calculate_cost result")
|
||||
|
||||
|
||||
async def periodic_key_reset() -> None:
|
||||
|
||||
+41
-5
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from .auth import get_billing_key, validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .lightning import lightning_router
|
||||
@@ -154,9 +154,23 @@ async def topup_wallet_endpoint(
|
||||
raise HTTPException(status_code=400, detail="Token already spent")
|
||||
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Token value is too small to cover swap fees. {error_msg}",
|
||||
)
|
||||
elif "failed to melt" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to swap foreign mint token. {error_msg}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Failed to redeem token")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"topup_wallet_endpoint: unhandled error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
return {"msats": amount_msats}
|
||||
|
||||
@@ -205,8 +219,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(
|
||||
@@ -400,6 +415,27 @@ async def reset_child_key_spent(
|
||||
return {"success": True, "message": "Child key balance reset successfully."}
|
||||
|
||||
|
||||
@router.get("/cashu-refund/{payment_token_hash}")
|
||||
async def get_cashu_refund(
|
||||
payment_token_hash: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
|
||||
result = await session.get(CashuTransaction, payment_token_hash)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
if result.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
result.collected = True
|
||||
session.add(result)
|
||||
await session.commit()
|
||||
return {
|
||||
"refund_token": result.token,
|
||||
"amount": result.amount,
|
||||
"unit": result.unit,
|
||||
}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
|
||||
+374
-13
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,7 +17,13 @@ from ..wallet import (
|
||||
send_token,
|
||||
slow_filter_spend_proofs,
|
||||
)
|
||||
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
|
||||
from .db import (
|
||||
ApiKey,
|
||||
CashuTransaction,
|
||||
ModelRow,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
)
|
||||
from .log_manager import log_manager
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService, settings
|
||||
@@ -27,6 +34,8 @@ admin_router = APIRouter(prefix="/admin", include_in_schema=False)
|
||||
|
||||
admin_sessions: dict[str, int] = {}
|
||||
ADMIN_SESSION_DURATION = 3600
|
||||
# Usage analytics remain queryable up to 12 months.
|
||||
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
|
||||
|
||||
|
||||
def require_admin_api(request: Request) -> None:
|
||||
@@ -544,6 +553,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 +563,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 +580,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 +612,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 +630,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 +650,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 +679,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 +696,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -731,7 +757,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
|
||||
m for m in upstream_models if m.id not in db_model_ids
|
||||
]
|
||||
|
||||
return {
|
||||
@@ -794,6 +820,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 +876,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 +1004,25 @@ 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 +1050,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 +1058,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(
|
||||
@@ -943,18 +1150,55 @@ async def get_usage_metrics(
|
||||
default=15, ge=1, le=1440, description="Time interval in minutes"
|
||||
),
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
default=24,
|
||||
ge=1,
|
||||
le=MAX_USAGE_ANALYTICS_HOURS,
|
||||
description="Hours of history to analyze",
|
||||
),
|
||||
) -> dict:
|
||||
"""Get usage metrics aggregated by time interval."""
|
||||
return log_manager.get_usage_metrics(interval=interval, hours=hours)
|
||||
|
||||
|
||||
@admin_router.get("/api/usage/dashboard", dependencies=[Depends(require_admin_api)])
|
||||
async def get_usage_dashboard(
|
||||
request: Request,
|
||||
interval: int = Query(
|
||||
default=15, ge=1, le=1440, description="Time interval in minutes"
|
||||
),
|
||||
hours: int = Query(
|
||||
default=24,
|
||||
ge=1,
|
||||
le=MAX_USAGE_ANALYTICS_HOURS,
|
||||
description="Hours of history to analyze",
|
||||
),
|
||||
error_limit: int = Query(
|
||||
default=100, ge=1, le=1000, description="Maximum number of errors to return"
|
||||
),
|
||||
model_limit: int = Query(
|
||||
default=20, ge=1, le=100, description="Maximum number of models to return"
|
||||
),
|
||||
) -> dict:
|
||||
"""
|
||||
Get all dashboard analytics in one request.
|
||||
This runs one combined aggregation pass and avoids repeated scans.
|
||||
"""
|
||||
return log_manager.get_usage_dashboard(
|
||||
interval=interval,
|
||||
hours=hours,
|
||||
error_limit=error_limit,
|
||||
model_limit=model_limit,
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
|
||||
async def get_usage_summary(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
default=24,
|
||||
ge=1,
|
||||
le=MAX_USAGE_ANALYTICS_HOURS,
|
||||
description="Hours of history to analyze",
|
||||
),
|
||||
) -> dict:
|
||||
"""Get summary statistics for the specified time period."""
|
||||
@@ -965,7 +1209,10 @@ async def get_usage_summary(
|
||||
async def get_error_details(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
default=24,
|
||||
ge=1,
|
||||
le=MAX_USAGE_ANALYTICS_HOURS,
|
||||
description="Hours of history to analyze",
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100, ge=1, le=1000, description="Maximum number of errors to return"
|
||||
@@ -981,7 +1228,10 @@ async def get_error_details(
|
||||
async def get_revenue_by_model(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
default=24,
|
||||
ge=1,
|
||||
le=MAX_USAGE_ANALYTICS_HOURS,
|
||||
description="Hours of history to analyze",
|
||||
),
|
||||
limit: int = Query(
|
||||
default=20, ge=1, le=100, description="Maximum number of models to return"
|
||||
@@ -1075,3 +1325,114 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
|
||||
continue
|
||||
|
||||
return {"dates": dates}
|
||||
|
||||
|
||||
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
|
||||
async def get_transactions_api(
|
||||
type: str | None = None,
|
||||
status: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
async with create_session() as session:
|
||||
from sqlmodel import col
|
||||
|
||||
stmt = select(CashuTransaction)
|
||||
if type:
|
||||
stmt = stmt.where(CashuTransaction.type == type)
|
||||
if status:
|
||||
if status == "collected":
|
||||
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
|
||||
elif status == "swept":
|
||||
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
|
||||
elif status == "pending":
|
||||
stmt = stmt.where(
|
||||
CashuTransaction.collected == False, # noqa: E712
|
||||
CashuTransaction.swept == False, # noqa: E712
|
||||
)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
stmt = stmt.where(
|
||||
(col(CashuTransaction.id).like(search_pattern))
|
||||
| (col(CashuTransaction.token).like(search_pattern))
|
||||
| (col(CashuTransaction.request_id).like(search_pattern))
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
|
||||
|
||||
results = await session.exec(stmt)
|
||||
transactions = results.all()
|
||||
|
||||
return {
|
||||
"transactions": [tx.dict() for tx in transactions],
|
||||
"total": len(transactions),
|
||||
}
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
+82
-2
@@ -2,11 +2,13 @@ import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.util.exc import CommandError
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||
@@ -128,6 +130,59 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||
|
||||
|
||||
class CashuTransaction(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "cashu_transactions"
|
||||
|
||||
id: str = Field(
|
||||
primary_key=True,
|
||||
default_factory=lambda: uuid.uuid4().hex,
|
||||
description="Unique transaction identifier",
|
||||
)
|
||||
token: str = Field(description="Serialized Cashu token")
|
||||
amount: int = Field(description="Amount in the token's unit")
|
||||
unit: str = Field(description="Token unit (sat or msat)")
|
||||
mint_url: str | None = Field(default=None, description="Mint URL for the token")
|
||||
type: str = Field(default="out", description="Transaction type: in or out")
|
||||
request_id: str | None = Field(default=None, description="Associated request ID")
|
||||
created_at: int = Field(
|
||||
default_factory=lambda: int(time.time()),
|
||||
description="Unix timestamp",
|
||||
)
|
||||
collected: bool = Field(default=False)
|
||||
swept: bool = Field(default=False)
|
||||
|
||||
|
||||
async def store_cashu_transaction(
|
||||
token: str,
|
||||
amount: int,
|
||||
unit: str,
|
||||
mint_url: str | None = None,
|
||||
typ: str = "out",
|
||||
request_id: str | None = None,
|
||||
collected: bool = False,
|
||||
created_at: int | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
tx = CashuTransaction(
|
||||
token=token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint_url,
|
||||
type=typ,
|
||||
request_id=request_id,
|
||||
collected=collected,
|
||||
created_at=created_at or int(time.time()),
|
||||
)
|
||||
session.add(tx)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to store cashu transaction: {e} (type={typ})",
|
||||
extra={"error": str(e), "type": typ},
|
||||
)
|
||||
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "upstream_providers"
|
||||
__table_args__ = (
|
||||
@@ -148,6 +203,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"},
|
||||
@@ -224,6 +282,17 @@ def fix_cashu_migrations() -> None:
|
||||
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
|
||||
|
||||
|
||||
def _clear_alembic_version() -> None:
|
||||
"""Clear the alembic_version table so stamp/upgrade can proceed."""
|
||||
sync_url = DATABASE_URL.replace("+aiosqlite", "")
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
eng = create_engine(sync_url)
|
||||
with eng.begin() as conn:
|
||||
conn.execute(text("DELETE FROM alembic_version"))
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
"""Run Alembic migrations programmatically."""
|
||||
try:
|
||||
@@ -245,8 +314,19 @@ def run_migrations() -> None:
|
||||
# Set the database URL in the config
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Run migrations to the latest revision
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
except CommandError as e:
|
||||
if "Can't locate revision" in str(e):
|
||||
logger.warning(
|
||||
"Database stamped with unknown revision (likely from another branch). "
|
||||
"Re-stamping to current head.",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
_clear_alembic_version()
|
||||
command.stamp(alembic_cfg, "head")
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info("Database migrations completed successfully")
|
||||
|
||||
|
||||
+813
-204
File diff suppressed because it is too large
Load Diff
+13
-11
@@ -3,36 +3,38 @@ Logging configuration for Routstr.
|
||||
|
||||
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
|
||||
===========================================
|
||||
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
|
||||
The following log messages are parsed by the usage tracking system
|
||||
(routstr/core/usage_analytics_store.py and routstr/core/log_manager.py).
|
||||
DO NOT modify or remove these messages without updating the usage tracking logic:
|
||||
|
||||
1. "Received proxy request" (INFO) - routstr/proxy.py
|
||||
- Used to count total incoming requests
|
||||
- Includes model information in context
|
||||
|
||||
2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py
|
||||
"Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
|
||||
2. "Calculated token-based cost" (INFO) - routstr/auth.py
|
||||
- Used to track successful completions and revenue
|
||||
- The 'cost_data.total_msats' field is extracted for revenue calculation
|
||||
- Must include 'cost_data' in extra dict
|
||||
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
|
||||
|
||||
3. "Payment processed successfully" (INFO) - routstr/auth.py
|
||||
3. "Max cost payment finalized" (INFO) - routstr/auth.py
|
||||
- Used as the successful completion fallback when token usage is unavailable
|
||||
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
|
||||
|
||||
4. "Payment processed successfully" (INFO) - routstr/auth.py
|
||||
- Used to count successful payment processing events
|
||||
- Tracks payment-related metrics
|
||||
|
||||
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
|
||||
5. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
|
||||
- Used to track failed requests and refunds
|
||||
- The 'max_cost_for_model' field is extracted for refund calculation
|
||||
- Must include 'max_cost_for_model' in extra dict
|
||||
|
||||
5. Any ERROR level logs with "upstream" in the message
|
||||
6. Any ERROR level logs with "upstream" in the message
|
||||
- Used to count upstream provider errors
|
||||
- Helps identify service reliability issues
|
||||
|
||||
If you need to modify these messages, ensure you also update the parsing logic in:
|
||||
- routstr/core/admin.py:_aggregate_metrics_by_time()
|
||||
- routstr/core/admin.py:_get_summary_stats()
|
||||
- routstr/core/admin.py:get_revenue_by_model()
|
||||
- routstr/core/usage_analytics_store.py
|
||||
- routstr/core/log_manager.py
|
||||
"""
|
||||
|
||||
import logging.config
|
||||
|
||||
+28
-5
@@ -12,12 +12,17 @@ from starlette.exceptions import HTTPException
|
||||
|
||||
from ..auth import periodic_key_reset
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..nostr import announce_provider, providers_cache_refresher
|
||||
from ..nostr import (
|
||||
announce_provider,
|
||||
providers_cache_refresher,
|
||||
publish_usage_analytics,
|
||||
)
|
||||
from ..nostr.discovery import providers_router
|
||||
from ..payment.models import models_router, update_sats_pricing
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..wallet import periodic_payout
|
||||
from ..upstream.auto_topup import periodic_auto_topup
|
||||
from ..wallet import periodic_payout, periodic_refund_sweep
|
||||
from .admin import admin_router
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .exceptions import general_exception_handler, http_exception_handler
|
||||
@@ -31,9 +36,9 @@ setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
__version__ = f"0.4.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -44,10 +49,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
analytics_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
auto_topup_task = None
|
||||
refund_sweep_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
@@ -101,9 +109,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
if global_settings.nsec:
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
analytics_task = asyncio.create_task(publish_usage_analytics())
|
||||
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())
|
||||
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
|
||||
|
||||
yield
|
||||
|
||||
@@ -127,6 +138,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
payout_task.cancel()
|
||||
if nip91_task is not None:
|
||||
nip91_task.cancel()
|
||||
if analytics_task is not None:
|
||||
analytics_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
@@ -135,6 +148,10 @@ 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()
|
||||
if refund_sweep_task is not None:
|
||||
refund_sweep_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
@@ -146,6 +163,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if nip91_task is not None:
|
||||
tasks_to_wait.append(nip91_task)
|
||||
if analytics_task is not None:
|
||||
tasks_to_wait.append(analytics_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
@@ -154,6 +173,10 @@ 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 refund_sweep_task is not None:
|
||||
tasks_to_wait.append(refund_sweep_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
@@ -174,7 +197,7 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["x-routstr-request-id"],
|
||||
expose_headers=["x-routstr-request-id", "x-cashu"],
|
||||
)
|
||||
|
||||
# Add logging middleware
|
||||
|
||||
@@ -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(
|
||||
@@ -74,6 +74,7 @@ class Settings(BaseSettings):
|
||||
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
|
||||
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
|
||||
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
|
||||
refund_sweep_ttl_seconds: int = Field(default=86400, env="REFUND_SWEEP_TTL_SECONDS")
|
||||
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
@@ -92,6 +93,20 @@ class Settings(BaseSettings):
|
||||
|
||||
# Discovery
|
||||
relays: list[str] = Field(default_factory=list, env="RELAYS")
|
||||
enable_analytics_sharing: bool = Field(
|
||||
default=True, env="ENABLE_ANALYTICS_SHARING"
|
||||
)
|
||||
|
||||
def _normalize_settings_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Discard unknown keys from persisted settings."""
|
||||
normalized: dict[str, Any] = {}
|
||||
known_fields = Settings.__fields__
|
||||
|
||||
for key, value in data.items():
|
||||
if key in known_fields:
|
||||
normalized[key] = value
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _compute_primary_mint(cashu_mints: list[str]) -> str:
|
||||
@@ -231,16 +246,21 @@ class SettingsService:
|
||||
|
||||
db_id, db_data, _updated_at = row
|
||||
try:
|
||||
db_json = (
|
||||
db_json_raw = (
|
||||
json.loads(db_data) if isinstance(db_data, str) else dict(db_data)
|
||||
)
|
||||
if not isinstance(db_json_raw, dict):
|
||||
db_json_raw = {}
|
||||
except Exception:
|
||||
db_json = {}
|
||||
db_json_raw = {}
|
||||
db_json = _normalize_settings_data(db_json_raw)
|
||||
|
||||
valid_fields = set(env_resolved.dict().keys())
|
||||
merged_dict: dict[str, Any] = dict(env_resolved.dict())
|
||||
merged_dict.update(
|
||||
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
|
||||
{k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields}
|
||||
)
|
||||
merged_dict = Settings(**merged_dict).dict()
|
||||
|
||||
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
|
||||
if not merged_dict.get("primary_mint"):
|
||||
@@ -248,7 +268,7 @@ class SettingsService:
|
||||
merged_dict.get("cashu_mints", [])
|
||||
)
|
||||
|
||||
if any(k not in db_json for k in merged_dict.keys()):
|
||||
if db_json_raw != merged_dict:
|
||||
await db_session.exec( # type: ignore
|
||||
text(
|
||||
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
|
||||
@@ -271,7 +291,7 @@ class SettingsService:
|
||||
) -> Settings:
|
||||
async with cls._lock:
|
||||
current = cls.get()
|
||||
candidate_dict = {**current.dict(), **partial}
|
||||
candidate_dict = {**current.dict(), **_normalize_settings_data(partial)}
|
||||
candidate = Settings(**candidate_dict)
|
||||
from sqlmodel import text
|
||||
|
||||
@@ -305,8 +325,10 @@ class SettingsService:
|
||||
raise RuntimeError("Settings row missing")
|
||||
(data_str,) = row
|
||||
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
|
||||
valid_fields = set(settings.dict().keys())
|
||||
# Update in-place
|
||||
for k, v in data.items():
|
||||
setattr(settings, k, v)
|
||||
if k in valid_fields:
|
||||
setattr(settings, k, v)
|
||||
cls._current = settings
|
||||
return settings
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
from .analytics import publish_usage_analytics
|
||||
from .discovery import providers_cache_refresher
|
||||
from .listing import announce_provider
|
||||
|
||||
__all__ = ["providers_cache_refresher", "announce_provider"]
|
||||
__all__ = ["providers_cache_refresher", "announce_provider", "publish_usage_analytics"]
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nostr usage analytics publisher.
|
||||
Publishes a single replaceable analytics snapshot for each provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from nostr.event import Event
|
||||
from nostr.key import PrivateKey
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.log_manager import log_manager
|
||||
from ..core.settings import settings
|
||||
from .listing import nsec_to_keypair, publish_to_relay
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ANALYTICS_KIND = 38422
|
||||
ANALYTICS_SCHEMA = "routstr.analytics.snapshot.v1"
|
||||
DEFAULT_RELAYS = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
PUBLISH_INTERVAL_SECONDS = 15 * 60
|
||||
DISABLED_POLL_SECONDS = 60
|
||||
DASHBOARD_WINDOW_HOURS = 24
|
||||
DASHBOARD_INTERVAL_MINUTES = 60
|
||||
MODEL_LIMIT = 20
|
||||
WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = (
|
||||
("24h", 24, 60),
|
||||
("7d", 7 * 24, 6 * 60),
|
||||
("30d", 30 * 24, 24 * 60),
|
||||
("3m", 90 * 24, 24 * 60),
|
||||
("1y", 365 * 24, 7 * 24 * 60),
|
||||
)
|
||||
|
||||
|
||||
def _event_to_dict(ev: Event) -> dict[str, Any]:
|
||||
return {
|
||||
"id": ev.id,
|
||||
"pubkey": ev.public_key,
|
||||
"created_at": ev.created_at,
|
||||
"kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind,
|
||||
"tags": ev.tags,
|
||||
"content": ev.content,
|
||||
"sig": ev.signature,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_provider_id(public_key_hex: str) -> str:
|
||||
explicit_provider_id = (settings.provider_id or "").strip()
|
||||
if explicit_provider_id:
|
||||
return explicit_provider_id
|
||||
return public_key_hex[:12]
|
||||
|
||||
|
||||
def _resolve_endpoint_urls() -> list[str]:
|
||||
urls: list[str] = []
|
||||
http_url = (settings.http_url or "").strip()
|
||||
onion_url = (settings.onion_url or "").strip()
|
||||
|
||||
if http_url and http_url != "http://localhost:8000":
|
||||
urls.append(http_url)
|
||||
|
||||
if onion_url:
|
||||
if onion_url.endswith(".onion") and not (
|
||||
onion_url.startswith("http://") or onion_url.startswith("https://")
|
||||
):
|
||||
onion_url = f"http://{onion_url}"
|
||||
urls.append(onion_url)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def _resolve_relays() -> list[str]:
|
||||
configured = [url.strip() for url in settings.relays if url.strip()]
|
||||
return configured if configured else list(DEFAULT_RELAYS)
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float:
|
||||
if isinstance(value, bool):
|
||||
return float(int(value))
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def _aggregate_top_model_usage(
|
||||
model_usage_mix: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
top_models_raw = model_usage_mix.get("top_models", [])
|
||||
mix_metrics_raw = model_usage_mix.get("metrics", [])
|
||||
|
||||
top_models = [model for model in top_models_raw if isinstance(model, str)]
|
||||
metrics = [row for row in mix_metrics_raw if isinstance(row, dict)]
|
||||
|
||||
model_totals: dict[str, dict[str, float | int]] = {
|
||||
model: {
|
||||
"successful_requests": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
for model in top_models
|
||||
}
|
||||
others = {
|
||||
"successful_requests": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
|
||||
for metric in metrics:
|
||||
model_counts = metric.get("model_counts", {})
|
||||
model_revenue = metric.get("model_revenue_msats", {})
|
||||
model_tokens = metric.get("model_tokens", {})
|
||||
|
||||
if isinstance(model_counts, dict):
|
||||
for model, count in model_counts.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["successful_requests"] += _to_int(count)
|
||||
|
||||
if isinstance(model_revenue, dict):
|
||||
for model, amount in model_revenue.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["revenue_msats"] += _to_float(amount)
|
||||
|
||||
if isinstance(model_tokens, dict):
|
||||
for model, token_count in model_tokens.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["total_tokens"] += _to_int(token_count)
|
||||
|
||||
others["successful_requests"] += _to_int(metric.get("others", 0))
|
||||
others["revenue_msats"] += _to_float(metric.get("others_revenue_msats", 0.0))
|
||||
others["total_tokens"] += _to_int(metric.get("others_tokens", 0))
|
||||
|
||||
model_rows = [
|
||||
{
|
||||
"model": model,
|
||||
"successful_requests": int(values["successful_requests"]),
|
||||
"revenue_msats": float(values["revenue_msats"]),
|
||||
"total_tokens": int(values["total_tokens"]),
|
||||
}
|
||||
for model, values in model_totals.items()
|
||||
]
|
||||
model_rows.sort(
|
||||
key=lambda row: _to_int(row.get("successful_requests", 0)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return model_rows, others
|
||||
|
||||
|
||||
def _build_summary_payload(summary: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"total_requests": _to_int(summary.get("total_requests", 0)),
|
||||
"successful_chat_completions": _to_int(
|
||||
summary.get("successful_chat_completions", 0)
|
||||
),
|
||||
"failed_requests": _to_int(summary.get("failed_requests", 0)),
|
||||
"success_rate": _to_float(summary.get("success_rate", 0.0)),
|
||||
"unique_models_count": _to_int(summary.get("unique_models_count", 0)),
|
||||
"input_tokens": _to_int(summary.get("input_tokens", 0)),
|
||||
"output_tokens": _to_int(summary.get("output_tokens", 0)),
|
||||
"total_tokens": _to_int(summary.get("total_tokens", 0)),
|
||||
"revenue_msats": _to_float(summary.get("revenue_msats", 0.0)),
|
||||
"refunds_msats": _to_float(summary.get("refunds_msats", 0.0)),
|
||||
"net_revenue_msats": _to_float(summary.get("net_revenue_msats", 0.0)),
|
||||
"revenue_sats": _to_float(summary.get("revenue_sats", 0.0)),
|
||||
"refunds_sats": _to_float(summary.get("refunds_sats", 0.0)),
|
||||
"net_revenue_sats": _to_float(summary.get("net_revenue_sats", 0.0)),
|
||||
}
|
||||
|
||||
|
||||
def _build_window_payload(
|
||||
*,
|
||||
hours: int,
|
||||
interval_minutes: int,
|
||||
model_limit: int,
|
||||
) -> dict[str, Any]:
|
||||
dashboard = log_manager.get_usage_dashboard(
|
||||
interval=interval_minutes,
|
||||
hours=hours,
|
||||
error_limit=1,
|
||||
model_limit=model_limit,
|
||||
)
|
||||
|
||||
summary = dashboard.get("summary", {})
|
||||
model_usage_mix = dashboard.get("model_usage_mix", {})
|
||||
|
||||
summary_payload = _build_summary_payload(summary if isinstance(summary, dict) else {})
|
||||
usage_mix_payload = model_usage_mix if isinstance(model_usage_mix, dict) else {}
|
||||
top_model_usage, others_usage = _aggregate_top_model_usage(usage_mix_payload)
|
||||
|
||||
return {
|
||||
"window_hours": hours,
|
||||
"interval_minutes": interval_minutes,
|
||||
"summary": summary_payload,
|
||||
"model_usage_mix": usage_mix_payload,
|
||||
"top_model_usage": top_model_usage,
|
||||
"others_usage": others_usage,
|
||||
}
|
||||
|
||||
|
||||
def build_stats_snapshot_payload(
|
||||
provider_id: str,
|
||||
*,
|
||||
public_key_hex: str,
|
||||
generated_at: int,
|
||||
window_hours: int = DASHBOARD_WINDOW_HOURS,
|
||||
interval_minutes: int = DASHBOARD_INTERVAL_MINUTES,
|
||||
model_limit: int = MODEL_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
_ = (window_hours, interval_minutes)
|
||||
windows: dict[str, dict[str, Any]] = {}
|
||||
for key, hours, window_interval_minutes in WINDOW_DEFINITIONS:
|
||||
windows[key] = _build_window_payload(
|
||||
hours=hours,
|
||||
interval_minutes=window_interval_minutes,
|
||||
model_limit=model_limit,
|
||||
)
|
||||
|
||||
primary_window = windows.get("24h", {})
|
||||
summary_payload = (
|
||||
primary_window.get("summary", {})
|
||||
if isinstance(primary_window.get("summary", {}), dict)
|
||||
else {}
|
||||
)
|
||||
usage_mix_payload = (
|
||||
primary_window.get("model_usage_mix", {})
|
||||
if isinstance(primary_window.get("model_usage_mix", {}), dict)
|
||||
else {}
|
||||
)
|
||||
top_model_usage = (
|
||||
primary_window.get("top_model_usage", [])
|
||||
if isinstance(primary_window.get("top_model_usage", []), list)
|
||||
else []
|
||||
)
|
||||
others_usage = (
|
||||
primary_window.get("others_usage", {})
|
||||
if isinstance(primary_window.get("others_usage", {}), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": ANALYTICS_SCHEMA,
|
||||
"generated_at": generated_at,
|
||||
"provider_id": provider_id,
|
||||
"pubkey": public_key_hex,
|
||||
"npub": settings.npub or "",
|
||||
"endpoint_urls": _resolve_endpoint_urls(),
|
||||
"window_hours": DASHBOARD_WINDOW_HOURS,
|
||||
"interval_minutes": DASHBOARD_INTERVAL_MINUTES,
|
||||
"summary": summary_payload,
|
||||
"model_usage_mix": usage_mix_payload,
|
||||
"top_model_usage": top_model_usage,
|
||||
"others_usage": others_usage,
|
||||
"windows": windows,
|
||||
}
|
||||
|
||||
|
||||
def create_stats_snapshot_event(
|
||||
private_key_hex: str,
|
||||
provider_id: str,
|
||||
payload_json: str,
|
||||
*,
|
||||
d_tag: str,
|
||||
) -> dict[str, Any]:
|
||||
private_key = PrivateKey(bytes.fromhex(private_key_hex))
|
||||
tags = [
|
||||
["d", d_tag],
|
||||
["provider", provider_id],
|
||||
["schema", ANALYTICS_SCHEMA],
|
||||
]
|
||||
|
||||
event = Event(
|
||||
public_key=private_key.public_key.hex(),
|
||||
content=payload_json,
|
||||
kind=ANALYTICS_KIND,
|
||||
tags=tags,
|
||||
)
|
||||
private_key.sign_event(event)
|
||||
return _event_to_dict(event)
|
||||
|
||||
|
||||
def _fingerprint_payload(payload: dict[str, Any]) -> str:
|
||||
normalized = dict(payload)
|
||||
# Ignore generated timestamp for semantic dedupe.
|
||||
normalized.pop("generated_at", None)
|
||||
payload_json = json.dumps(normalized, separators=(",", ":"), sort_keys=True)
|
||||
return hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def publish_usage_analytics() -> None:
|
||||
last_payload_hash: str | None = None
|
||||
|
||||
parsed_nsec: str | None = None
|
||||
private_key_hex: str | None = None
|
||||
public_key_hex: str | None = None
|
||||
provider_id: str | None = None
|
||||
warned_missing_nsec = False
|
||||
|
||||
logger.info("Usage analytics sharing task started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
if not settings.enable_analytics_sharing:
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
nsec = (settings.nsec or "").strip()
|
||||
if not nsec:
|
||||
if not warned_missing_nsec:
|
||||
logger.info("NSEC is not configured; skipping analytics sharing to Nostr")
|
||||
warned_missing_nsec = True
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
warned_missing_nsec = False
|
||||
if nsec != parsed_nsec or private_key_hex is None or public_key_hex is None:
|
||||
keypair = nsec_to_keypair(nsec)
|
||||
if not keypair:
|
||||
logger.error("Invalid NSEC; analytics sharing is paused")
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
private_key_hex, public_key_hex = keypair
|
||||
parsed_nsec = nsec
|
||||
provider_id = _resolve_provider_id(public_key_hex)
|
||||
last_payload_hash = None
|
||||
|
||||
if private_key_hex is None or public_key_hex is None:
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
relay_urls = _resolve_relays()
|
||||
if not relay_urls:
|
||||
logger.warning("No Nostr relays configured; analytics sharing skipped")
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
resolved_provider_id = provider_id or _resolve_provider_id(public_key_hex)
|
||||
now_ts = int(time.time())
|
||||
payload = build_stats_snapshot_payload(
|
||||
resolved_provider_id,
|
||||
public_key_hex=public_key_hex,
|
||||
generated_at=now_ts,
|
||||
)
|
||||
|
||||
payload_hash = _fingerprint_payload(payload)
|
||||
if last_payload_hash == payload_hash:
|
||||
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
|
||||
continue
|
||||
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
||||
d_tag = f"{resolved_provider_id}:stats"
|
||||
event = create_stats_snapshot_event(
|
||||
private_key_hex,
|
||||
resolved_provider_id,
|
||||
payload_json,
|
||||
d_tag=d_tag,
|
||||
)
|
||||
|
||||
success_count = 0
|
||||
for relay_url in relay_urls:
|
||||
if await publish_to_relay(relay_url, event):
|
||||
success_count += 1
|
||||
|
||||
if success_count > 0:
|
||||
last_payload_hash = payload_hash
|
||||
|
||||
logger.info(
|
||||
"Published analytics snapshot (success=%s/%s provider=%s)",
|
||||
success_count,
|
||||
len(relay_urls),
|
||||
resolved_provider_id,
|
||||
extra={
|
||||
"relay_success_count": success_count,
|
||||
"relay_total": len(relay_urls),
|
||||
"provider_id": resolved_provider_id,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Usage analytics sharing task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Usage analytics sharing error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
@@ -16,6 +16,8 @@ class CostData(BaseModel):
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
total_usd: float = 0.0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
@@ -63,10 +65,49 @@ async def calculate_cost( # todo: can be sync
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
total_usd=0.0,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
)
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
def parse_token_count(value: object) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if isinstance(value, float):
|
||||
return max(0, int(value))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
|
||||
output_tokens = parse_token_count(usage_data.get("completion_tokens", 0))
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else parse_token_count(usage_data.get("input_tokens", 0))
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else parse_token_count(usage_data.get("output_tokens", 0))
|
||||
)
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else parse_token_count(response_data.get("usage", {}).get("input_tokens", 0))
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else parse_token_count(response_data.get("usage", {}).get("output_tokens", 0))
|
||||
)
|
||||
|
||||
usd_cost = 0.0
|
||||
|
||||
# Prioritize cost_details.upstream_inference_cost
|
||||
@@ -104,6 +145,8 @@ async def calculate_cost( # todo: can be sync
|
||||
output_msats=-1,
|
||||
total_msats=cost_in_msats,
|
||||
total_usd=usd_cost,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -184,31 +227,10 @@ async def calculate_cost( # todo: can be sync
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||
output_tokens = usage_data.get("completion_tokens", 0)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
|
||||
)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else response_data.get("usage", {}).get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else response_data.get("usage", {}).get("output_tokens", 0)
|
||||
)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
@@ -234,4 +256,6 @@ async def calculate_cost( # todo: can be sync
|
||||
output_msats=int(output_msats),
|
||||
total_msats=token_based_cost,
|
||||
total_usd=total_usd,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
@@ -29,16 +29,13 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
||||
},
|
||||
)
|
||||
elif auth := headers.get("authorization", None):
|
||||
parts = auth.split()
|
||||
cashu_token = parts[1] if len(parts) > 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")
|
||||
@@ -76,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,
|
||||
|
||||
+9
-10
@@ -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__,
|
||||
@@ -455,15 +455,14 @@ async def get_bearer_token_key(
|
||||
)
|
||||
return key
|
||||
except Exception as e:
|
||||
key_preview = bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key
|
||||
logger.error(
|
||||
"Bearer token validation failed",
|
||||
f"Bearer token validation failed: {type(e).__name__}: {e} path={path} key={key_preview!r}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"bearer_key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
"bearer_key_preview": key_preview,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
+45
-11
@@ -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
|
||||
|
||||
+278
-54
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
@@ -13,9 +14,15 @@ 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.db import (
|
||||
ApiKey,
|
||||
AsyncSession,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
store_cashu_transaction,
|
||||
)
|
||||
from ..core.exceptions import UpstreamError
|
||||
from ..payment.cost_calculation import (
|
||||
CostData,
|
||||
@@ -197,6 +204,21 @@ 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 +545,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 +563,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 +659,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 +853,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 +862,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 +886,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 +976,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 +1123,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 +1299,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,14 +1329,8 @@ 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(
|
||||
self,
|
||||
@@ -1279,11 +1358,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 +1511,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,14 +1541,8 @@ 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(
|
||||
self,
|
||||
@@ -1491,10 +1560,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",
|
||||
@@ -1613,13 +1680,22 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
return None
|
||||
|
||||
async def send_refund(self, amount: int, unit: str, mint: str | None = None) -> str:
|
||||
async def send_refund(
|
||||
self,
|
||||
amount: int,
|
||||
unit: str,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
"""Create and send a refund token to the user.
|
||||
|
||||
Args:
|
||||
amount: Refund amount
|
||||
unit: Unit of the refund (sat or msat)
|
||||
mint: Optional mint URL for the refund token
|
||||
payment_token_hash: Optional SHA-256 hash of the original payment token for storage
|
||||
request_id: Optional HTTP request ID for tracking
|
||||
|
||||
Returns:
|
||||
Refund token string
|
||||
@@ -1649,6 +1725,18 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception:
|
||||
pass # store_cashu_transaction already logs
|
||||
|
||||
return refund_token
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
@@ -1698,6 +1786,8 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> StreamingResponse:
|
||||
"""Handle streaming response for X-Cashu payment, calculating refund if needed.
|
||||
|
||||
@@ -1707,6 +1797,7 @@ class BaseUpstreamProvider:
|
||||
amount: Payment amount received
|
||||
unit: Payment unit (sat or msat)
|
||||
max_cost_for_model: Maximum cost for the model
|
||||
payment_token_hash: Optional hash of original payment token for refund storage
|
||||
|
||||
Returns:
|
||||
StreamingResponse with refund token in header if applicable
|
||||
@@ -1778,7 +1869,10 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await self.send_refund(refund_amount, unit, mint)
|
||||
refund_token = await self.send_refund(
|
||||
refund_amount, unit, mint, payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.info(
|
||||
@@ -1831,6 +1925,8 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> Response:
|
||||
"""Handle non-streaming response for X-Cashu payment, calculating refund if needed.
|
||||
|
||||
@@ -1840,6 +1936,7 @@ class BaseUpstreamProvider:
|
||||
amount: Payment amount received
|
||||
unit: Payment unit (sat or msat)
|
||||
max_cost_for_model: Maximum cost for the model
|
||||
payment_token_hash: Optional hash of original payment token for refund storage
|
||||
|
||||
Returns:
|
||||
Response with refund token in header if applicable
|
||||
@@ -1901,7 +1998,10 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
refund_token = await self.send_refund(refund_amount, unit, mint)
|
||||
refund_token = await self.send_refund(
|
||||
refund_amount, unit, mint, payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.info(
|
||||
@@ -1937,6 +2037,17 @@ class BaseUpstreamProvider:
|
||||
emergency_refund = amount
|
||||
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
|
||||
response.headers["X-Cashu"] = refund_token
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=emergency_refund,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(
|
||||
"Emergency refund issued due to JSON parse error",
|
||||
@@ -1961,6 +2072,8 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> StreamingResponse | Response:
|
||||
"""Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming.
|
||||
|
||||
@@ -1997,11 +2110,25 @@ class BaseUpstreamProvider:
|
||||
|
||||
if is_streaming:
|
||||
return await self.handle_x_cashu_streaming_response(
|
||||
content_str, response, amount, unit, max_cost_for_model, mint
|
||||
content_str,
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
else:
|
||||
return await self.handle_x_cashu_non_streaming_response(
|
||||
content_str, response, amount, unit, max_cost_for_model, mint
|
||||
content_str,
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -2030,6 +2157,7 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model: int,
|
||||
model_obj: Model,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward request paid with X-Cashu token to upstream service.
|
||||
|
||||
@@ -2100,7 +2228,10 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await self.send_refund(amount - 60, unit, mint)
|
||||
refund_token = await self.send_refund(
|
||||
amount - 60, unit, mint, payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Refund processed for failed upstream request",
|
||||
@@ -2138,7 +2269,13 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
result = await self.handle_x_cashu_chat_completion(
|
||||
response, amount, unit, max_cost_for_model, mint
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
@@ -2213,10 +2350,25 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
try:
|
||||
payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest()
|
||||
headers = dict(request.headers)
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
headers = self.prepare_headers(dict(request.headers))
|
||||
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=x_cashu_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="in",
|
||||
request_id=request_id,
|
||||
collected=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"X-Cashu token redeemed for Responses API",
|
||||
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
|
||||
@@ -2231,6 +2383,7 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model,
|
||||
model_obj,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
@@ -2290,6 +2443,7 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model: int,
|
||||
model_obj: Model,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward Responses API request paid with X-Cashu token to upstream service.
|
||||
|
||||
@@ -2361,7 +2515,10 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await self.send_refund(amount - 60, unit, mint)
|
||||
refund_token = await self.send_refund(
|
||||
amount - 60, unit, mint, payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Refund processed for failed upstream Responses API request",
|
||||
@@ -2399,7 +2556,13 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
result = await self.handle_x_cashu_responses_completion(
|
||||
response, amount, unit, max_cost_for_model, mint
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
@@ -2449,6 +2612,8 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> StreamingResponse | Response:
|
||||
"""Handle Responses API completion response for X-Cashu payment.
|
||||
|
||||
@@ -2486,11 +2651,25 @@ class BaseUpstreamProvider:
|
||||
|
||||
if is_streaming:
|
||||
return await self.handle_x_cashu_streaming_responses_response(
|
||||
content_str, response, amount, unit, max_cost_for_model, mint
|
||||
content_str,
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
else:
|
||||
return await self.handle_x_cashu_non_streaming_responses_response(
|
||||
content_str, response, amount, unit, max_cost_for_model, mint
|
||||
content_str,
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -2517,6 +2696,8 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> StreamingResponse:
|
||||
"""Handle streaming Responses API response for X-Cashu payment.
|
||||
|
||||
@@ -2598,7 +2779,10 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await self.send_refund(refund_amount, unit, mint)
|
||||
refund_token = await self.send_refund(
|
||||
refund_amount, unit, mint, payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.info(
|
||||
@@ -2651,6 +2835,8 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> Response:
|
||||
"""Handle non-streaming Responses API response for X-Cashu payment."""
|
||||
logger.debug(
|
||||
@@ -2710,7 +2896,10 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
refund_token = await self.send_refund(refund_amount, unit, mint)
|
||||
refund_token = await self.send_refund(
|
||||
refund_amount, unit, mint, payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.info(
|
||||
@@ -2746,6 +2935,17 @@ class BaseUpstreamProvider:
|
||||
emergency_refund = amount
|
||||
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
|
||||
response.headers["X-Cashu"] = refund_token
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=emergency_refund,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(
|
||||
"Emergency refund issued for Responses API due to JSON parse error",
|
||||
@@ -2795,10 +2995,25 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
try:
|
||||
payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest()
|
||||
headers = dict(request.headers)
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
headers = self.prepare_headers(dict(request.headers))
|
||||
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=x_cashu_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="in",
|
||||
request_id=request_id,
|
||||
collected=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"X-Cashu token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
|
||||
@@ -2813,6 +3028,7 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model,
|
||||
model_obj,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
@@ -3024,10 +3240,10 @@ class BaseUpstreamProvider:
|
||||
async with create_session() as session:
|
||||
stmt = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == self.base_url,
|
||||
UpstreamProviderRow.api_key == self.api_key
|
||||
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:
|
||||
@@ -3045,16 +3261,24 @@ class BaseUpstreamProvider:
|
||||
diff = set(db_model_ids) - set(model_ids)
|
||||
|
||||
for db_model_id in diff:
|
||||
found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id))
|
||||
found_db_model = next(
|
||||
(
|
||||
model_obj
|
||||
for model_obj in db_models
|
||||
if model_obj.id == db_model_id
|
||||
)
|
||||
)
|
||||
models.append(found_db_model)
|
||||
|
||||
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
||||
print([mode.id for mode in models_with_fees])
|
||||
models_with_fees = [
|
||||
self._apply_provider_fee_to_model(m) for m in models
|
||||
]
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
self._models_cache = [
|
||||
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
|
||||
_update_model_sats_pricing(m, sats_to_usd)
|
||||
for m in models_with_fees
|
||||
]
|
||||
except Exception:
|
||||
self._models_cache = models_with_fees
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}
|
||||
+157
-10
@@ -1,11 +1,12 @@
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import TypedDict
|
||||
|
||||
from cashu.core.base import Proof, Token
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from sqlmodel import col, update
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .core import db, get_logger
|
||||
from .core.settings import settings
|
||||
@@ -34,6 +35,7 @@ async def recieve_token(
|
||||
|
||||
wallet.verify_proofs_dleq(token_obj.proofs)
|
||||
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
|
||||
return token_obj.amount, token_obj.unit, token_obj.mint
|
||||
|
||||
|
||||
@@ -62,11 +64,12 @@ async def swap_to_primary_mint(
|
||||
token_obj: Token, token_wallet: Wallet
|
||||
) -> tuple[int, str, str]:
|
||||
logger.info(
|
||||
"swap_to_primary_mint",
|
||||
"swap_to_primary_mint: starting",
|
||||
extra={
|
||||
"mint": token_obj.mint,
|
||||
"amount": token_obj.amount,
|
||||
"foreign_mint": token_obj.mint,
|
||||
"token_amount": token_obj.amount,
|
||||
"unit": token_obj.unit,
|
||||
"primary_mint": settings.primary_mint,
|
||||
},
|
||||
)
|
||||
# Ensure amount is an integer
|
||||
@@ -89,16 +92,105 @@ async def swap_to_primary_mint(
|
||||
minted_amount = int(amount_msat_after_fee // 1000)
|
||||
else:
|
||||
minted_amount = int(amount_msat_after_fee)
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: fee estimation",
|
||||
extra={
|
||||
"token_amount_sat": amount_msat // 1000,
|
||||
"estimated_fee_sat": estimated_fee_sat,
|
||||
"minted_amount": minted_amount,
|
||||
"minted_unit": settings.primary_mint_unit,
|
||||
},
|
||||
)
|
||||
|
||||
mint_quote = await primary_wallet.request_mint(minted_amount)
|
||||
logger.info(
|
||||
"swap_to_primary_mint: mint quote received",
|
||||
extra={"mint_quote_id": mint_quote.quote},
|
||||
)
|
||||
|
||||
melt_quote = await token_wallet.melt_quote(mint_quote.request)
|
||||
_ = await token_wallet.melt(
|
||||
proofs=token_obj.proofs,
|
||||
invoice=mint_quote.request,
|
||||
fee_reserve_sat=melt_quote.fee_reserve,
|
||||
quote_id=melt_quote.quote,
|
||||
total_needed = melt_quote.amount + melt_quote.fee_reserve
|
||||
logger.info(
|
||||
"swap_to_primary_mint: melt quote received",
|
||||
extra={
|
||||
"melt_quote_id": melt_quote.quote,
|
||||
"melt_amount": melt_quote.amount,
|
||||
"melt_fee_reserve": melt_quote.fee_reserve,
|
||||
"total_needed": total_needed,
|
||||
"token_amount": token_amount,
|
||||
},
|
||||
)
|
||||
|
||||
if total_needed > token_amount:
|
||||
logger.warning(
|
||||
"swap_to_primary_mint: insufficient token amount for melt fees",
|
||||
extra={
|
||||
"token_amount": token_amount,
|
||||
"melt_amount": melt_quote.amount,
|
||||
"melt_fee_reserve": melt_quote.fee_reserve,
|
||||
"total_needed": total_needed,
|
||||
"shortfall": total_needed - token_amount,
|
||||
},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Token amount ({token_amount} {token_obj.unit}) is insufficient to cover "
|
||||
f"melt fees. Needed: {total_needed} {token_obj.unit} "
|
||||
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve})"
|
||||
)
|
||||
|
||||
try:
|
||||
_ = await token_wallet.melt(
|
||||
proofs=token_obj.proofs,
|
||||
invoice=mint_quote.request,
|
||||
fee_reserve_sat=melt_quote.fee_reserve,
|
||||
quote_id=melt_quote.quote,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: melt failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"foreign_mint": token_obj.mint,
|
||||
"token_amount": token_amount,
|
||||
"melt_quote_id": melt_quote.quote,
|
||||
"total_needed": total_needed,
|
||||
},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Failed to melt token from foreign mint {token_obj.mint}: {e}"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: melt succeeded, minting on primary",
|
||||
extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote},
|
||||
)
|
||||
|
||||
try:
|
||||
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: mint on primary failed after successful melt",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"minted_amount": minted_amount,
|
||||
"mint_quote_id": mint_quote.quote,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: completed successfully",
|
||||
extra={
|
||||
"foreign_mint": token_obj.mint,
|
||||
"primary_mint": settings.primary_mint,
|
||||
"original_amount": token_amount,
|
||||
"minted_amount": minted_amount,
|
||||
"unit": settings.primary_mint_unit,
|
||||
},
|
||||
)
|
||||
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
|
||||
|
||||
return int(minted_amount), settings.primary_mint_unit, settings.primary_mint
|
||||
|
||||
@@ -352,6 +444,61 @@ async def periodic_payout() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def periodic_refund_sweep() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60 * 60) # every hour
|
||||
try:
|
||||
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
|
||||
async with db.create_session() as session:
|
||||
stmt = select(db.CashuTransaction).where(
|
||||
db.CashuTransaction.type == "out",
|
||||
db.CashuTransaction.collected == False, # noqa: E712
|
||||
db.CashuTransaction.swept == False, # noqa: E712
|
||||
db.CashuTransaction.created_at < cutoff,
|
||||
)
|
||||
results = await session.exec(stmt)
|
||||
refunds = results.all()
|
||||
|
||||
for refund in refunds:
|
||||
try:
|
||||
await recieve_token(refund.token)
|
||||
refund.swept = True
|
||||
session.add(refund)
|
||||
logger.info(
|
||||
"Swept uncollected refund",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
"amount": refund.amount,
|
||||
"unit": refund.unit,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already spent" in error_msg:
|
||||
refund.swept = True
|
||||
session.add(refund)
|
||||
logger.info(
|
||||
"Refund already spent (client collected), marking swept",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to sweep refund",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in periodic refund sweep",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
|
||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||
wallet = await get_wallet(mint, unit)
|
||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,143 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncGenerator, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import AsyncSession, ModelRow, UpstreamProviderRow
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
from routstr.proxy import refresh_model_maps
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_enforce_lowest_provider_fee_for_same_url(
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
"""Test that the algorithm selects the provider with the lowest fee when URLs match."""
|
||||
|
||||
# 1. Create two providers with the same URL but different fees
|
||||
url = "https://api.example.com"
|
||||
p1 = UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=url,
|
||||
api_key="key1",
|
||||
enabled=True,
|
||||
provider_fee=1.01,
|
||||
)
|
||||
p2 = UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=url,
|
||||
api_key="key2",
|
||||
enabled=True,
|
||||
provider_fee=1.05,
|
||||
)
|
||||
|
||||
integration_session.add(p1)
|
||||
integration_session.add(p2)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(p1)
|
||||
await integration_session.refresh(p2)
|
||||
|
||||
assert p1.id is not None
|
||||
assert p2.id is not None
|
||||
|
||||
# 2. Add a model for each provider
|
||||
m1 = ModelRow(
|
||||
id="model-a",
|
||||
name="Model A",
|
||||
created=1,
|
||||
description="desc",
|
||||
context_length=100,
|
||||
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
|
||||
pricing='{"prompt": 1.0, "completion": 1.0}',
|
||||
upstream_provider_id=p1.id,
|
||||
enabled=True,
|
||||
)
|
||||
m2 = ModelRow(
|
||||
id="model-a",
|
||||
name="Model A",
|
||||
created=1,
|
||||
description="desc",
|
||||
context_length=100,
|
||||
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
|
||||
pricing='{"prompt": 1.0, "completion": 1.0}',
|
||||
upstream_provider_id=p2.id,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
integration_session.add(m1)
|
||||
integration_session.add(m2)
|
||||
await integration_session.commit()
|
||||
|
||||
# 3. Create mock provider instances
|
||||
class MockProvider(BaseUpstreamProvider):
|
||||
db_id: int
|
||||
|
||||
def __init__(self, db_id: int, base_url: str, api_key: str, fee: float):
|
||||
super().__init__(base_url, api_key, fee)
|
||||
self.db_id = db_id
|
||||
self.provider_type = "custom"
|
||||
|
||||
def get_cached_models(self) -> list[Model]:
|
||||
return [
|
||||
Model(
|
||||
id="model-a",
|
||||
name="Model A",
|
||||
created=1,
|
||||
description="desc",
|
||||
context_length=100,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="tiktoken",
|
||||
instruct_type="chat",
|
||||
),
|
||||
pricing=Pricing(prompt=1.0, completion=1.0),
|
||||
enabled=True,
|
||||
upstream_provider_id=self.db_id,
|
||||
)
|
||||
]
|
||||
|
||||
async def refresh_models_cache(self) -> None:
|
||||
pass
|
||||
|
||||
def prepare_headers(self, request_headers: dict[str, str]) -> dict[str, str]:
|
||||
return request_headers
|
||||
|
||||
# 4. Inject mock providers into the proxy
|
||||
from routstr import proxy
|
||||
|
||||
assert p1.id is not None
|
||||
assert p2.id is not None
|
||||
|
||||
# Need to patch proxy._upstreams and proxy.create_session
|
||||
mp1: MockProvider = MockProvider(p1.id, url, "key1", 1.01)
|
||||
mp2: MockProvider = MockProvider(p2.id, url, "key2", 1.05)
|
||||
|
||||
with (
|
||||
patch("routstr.proxy._upstreams", [mp1, mp2]),
|
||||
patch("routstr.proxy.create_session") as mock_session_factory,
|
||||
):
|
||||
# Configure mock_session_factory to return a session that uses the test engine
|
||||
@asynccontextmanager
|
||||
async def mock_create_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield integration_session
|
||||
|
||||
mock_session_factory.return_value = mock_create_session()
|
||||
|
||||
await refresh_model_maps()
|
||||
|
||||
# 5. Check which provider is selected for 'model-a'
|
||||
provider_map = proxy.get_provider_for_model("model-a")
|
||||
|
||||
# Assertions
|
||||
assert provider_map is not None
|
||||
assert len(provider_map) >= 1
|
||||
|
||||
# Check the first one, cast to MockProvider to access db_id
|
||||
best_provider = cast(MockProvider, provider_map[0])
|
||||
assert best_provider.db_id == p1.id
|
||||
assert best_provider.provider_fee == 1.01
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.nostr import analytics
|
||||
|
||||
|
||||
def test_aggregate_top_model_usage_sums_metrics() -> None:
|
||||
model_usage_mix = {
|
||||
"top_models": ["openai/gpt-4o", "anthropic/claude-3.5-sonnet"],
|
||||
"metrics": [
|
||||
{
|
||||
"model_counts": {
|
||||
"openai/gpt-4o": 4,
|
||||
"anthropic/claude-3.5-sonnet": 2,
|
||||
},
|
||||
"model_revenue_msats": {
|
||||
"openai/gpt-4o": 1500,
|
||||
"anthropic/claude-3.5-sonnet": 700,
|
||||
},
|
||||
"model_tokens": {
|
||||
"openai/gpt-4o": 1200,
|
||||
"anthropic/claude-3.5-sonnet": 600,
|
||||
},
|
||||
"others": 1,
|
||||
"others_revenue_msats": 300,
|
||||
"others_tokens": 200,
|
||||
},
|
||||
{
|
||||
"model_counts": {
|
||||
"openai/gpt-4o": 3,
|
||||
"anthropic/claude-3.5-sonnet": 1,
|
||||
},
|
||||
"model_revenue_msats": {
|
||||
"openai/gpt-4o": 1000,
|
||||
"anthropic/claude-3.5-sonnet": 500,
|
||||
},
|
||||
"model_tokens": {
|
||||
"openai/gpt-4o": 800,
|
||||
"anthropic/claude-3.5-sonnet": 300,
|
||||
},
|
||||
"others": 2,
|
||||
"others_revenue_msats": 450,
|
||||
"others_tokens": 350,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rows, others = analytics._aggregate_top_model_usage(model_usage_mix)
|
||||
assert rows == [
|
||||
{
|
||||
"model": "openai/gpt-4o",
|
||||
"successful_requests": 7,
|
||||
"revenue_msats": 2500.0,
|
||||
"total_tokens": 2000,
|
||||
},
|
||||
{
|
||||
"model": "anthropic/claude-3.5-sonnet",
|
||||
"successful_requests": 3,
|
||||
"revenue_msats": 1200.0,
|
||||
"total_tokens": 900,
|
||||
},
|
||||
]
|
||||
assert others == {
|
||||
"successful_requests": 3,
|
||||
"revenue_msats": 750.0,
|
||||
"total_tokens": 550,
|
||||
}
|
||||
|
||||
|
||||
def test_build_stats_snapshot_payload_schema_and_shape(monkeypatch: Any) -> None:
|
||||
seen_windows: set[tuple[int, int]] = set()
|
||||
|
||||
def fake_usage_dashboard(
|
||||
*, interval: int, hours: int, error_limit: int, model_limit: int
|
||||
) -> dict[str, Any]:
|
||||
seen_windows.add((hours, interval))
|
||||
assert error_limit == 1
|
||||
assert model_limit == 20
|
||||
return {
|
||||
"summary": {
|
||||
"total_requests": hours,
|
||||
"successful_chat_completions": max(1, hours - 1),
|
||||
"failed_requests": 2,
|
||||
"success_rate": 90.0,
|
||||
"unique_models_count": 2,
|
||||
"input_tokens": 2000,
|
||||
"output_tokens": 1000,
|
||||
"total_tokens": 3000,
|
||||
"revenue_msats": 9000.0,
|
||||
"refunds_msats": 1000.0,
|
||||
"net_revenue_msats": 8000.0,
|
||||
"revenue_sats": 9.0,
|
||||
"refunds_sats": 1.0,
|
||||
"net_revenue_sats": 8.0,
|
||||
},
|
||||
"model_usage_mix": {
|
||||
"top_models": ["openai/gpt-4o"],
|
||||
"metrics": [
|
||||
{
|
||||
"timestamp": "2026-03-02 10:00:00",
|
||||
"model_counts": {"openai/gpt-4o": hours},
|
||||
"model_revenue_msats": {"openai/gpt-4o": float(hours * 100)},
|
||||
"model_tokens": {"openai/gpt-4o": hours * 10},
|
||||
"others": 4,
|
||||
"others_revenue_msats": 1800.0,
|
||||
"others_tokens": 400,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
analytics.log_manager, "get_usage_dashboard", fake_usage_dashboard
|
||||
)
|
||||
monkeypatch.setattr(analytics.settings, "npub", "npub1example")
|
||||
monkeypatch.setattr(analytics.settings, "http_url", "https://node.example.com")
|
||||
monkeypatch.setattr(analytics.settings, "onion_url", "")
|
||||
|
||||
payload = analytics.build_stats_snapshot_payload(
|
||||
"provider123",
|
||||
public_key_hex="ab" * 32,
|
||||
generated_at=1772451600,
|
||||
)
|
||||
|
||||
assert payload["schema"] == analytics.ANALYTICS_SCHEMA
|
||||
assert payload["provider_id"] == "provider123"
|
||||
assert payload["window_hours"] == 24
|
||||
assert payload["interval_minutes"] == 60
|
||||
assert payload["endpoint_urls"] == ["https://node.example.com"]
|
||||
assert seen_windows == {
|
||||
(24, 60),
|
||||
(7 * 24, 6 * 60),
|
||||
(30 * 24, 24 * 60),
|
||||
(90 * 24, 24 * 60),
|
||||
(365 * 24, 7 * 24 * 60),
|
||||
}
|
||||
assert set(payload["windows"].keys()) == {"24h", "7d", "30d", "3m", "1y"}
|
||||
assert payload["windows"]["1y"]["interval_minutes"] == 7 * 24 * 60
|
||||
assert payload["summary"]["total_requests"] == 24
|
||||
assert payload["top_model_usage"] == [
|
||||
{
|
||||
"model": "openai/gpt-4o",
|
||||
"successful_requests": 24,
|
||||
"revenue_msats": 2400.0,
|
||||
"total_tokens": 240,
|
||||
}
|
||||
]
|
||||
assert payload["others_usage"] == {
|
||||
"successful_requests": 4,
|
||||
"revenue_msats": 1800.0,
|
||||
"total_tokens": 400,
|
||||
}
|
||||
|
||||
|
||||
def test_create_stats_snapshot_event_tags() -> None:
|
||||
private_key_hex = "11" * 32
|
||||
event = analytics.create_stats_snapshot_event(
|
||||
private_key_hex,
|
||||
"provider123",
|
||||
payload_json='{"schema":"routstr.analytics.snapshot.v1"}',
|
||||
d_tag="provider123:stats",
|
||||
)
|
||||
|
||||
tags = event["tags"]
|
||||
assert ["d", "provider123:stats"] in tags
|
||||
assert ["provider", "provider123"] in tags
|
||||
assert ["schema", analytics.ANALYTICS_SCHEMA] in tags
|
||||
assert all(tag[0] != "period" for tag in tags)
|
||||
|
||||
|
||||
def test_fingerprint_payload_ignores_generated_at() -> None:
|
||||
a = {"schema": analytics.ANALYTICS_SCHEMA, "generated_at": 1000, "summary": {"x": 1}}
|
||||
b = {"schema": analytics.ANALYTICS_SCHEMA, "generated_at": 2000, "summary": {"x": 1}}
|
||||
|
||||
assert analytics._fingerprint_payload(a) == analytics._fingerprint_payload(b)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_usage_analytics_skips_when_disabled(monkeypatch: Any) -> None:
|
||||
delays: list[int] = []
|
||||
|
||||
async def fake_sleep(seconds: int) -> None:
|
||||
delays.append(seconds)
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
def fail_build(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
raise AssertionError("build_stats_snapshot_payload should not be called")
|
||||
|
||||
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", False)
|
||||
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fail_build)
|
||||
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
|
||||
|
||||
await analytics.publish_usage_analytics()
|
||||
|
||||
assert delays == [analytics.DISABLED_POLL_SECONDS]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_usage_analytics_skips_without_nsec(monkeypatch: Any) -> None:
|
||||
delays: list[int] = []
|
||||
|
||||
async def fake_sleep(seconds: int) -> None:
|
||||
delays.append(seconds)
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
def fail_build(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
raise AssertionError("build_stats_snapshot_payload should not be called")
|
||||
|
||||
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", True)
|
||||
monkeypatch.setattr(analytics.settings, "nsec", "")
|
||||
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fail_build)
|
||||
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
|
||||
|
||||
await analytics.publish_usage_analytics()
|
||||
|
||||
assert delays == [analytics.DISABLED_POLL_SECONDS]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_usage_analytics_dedupes_unchanged_payload(monkeypatch: Any) -> None:
|
||||
published_events: list[dict[str, Any]] = []
|
||||
sleep_calls = 0
|
||||
|
||||
async def fake_sleep(seconds: int) -> None:
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
def fake_build_payload(
|
||||
provider_id: str,
|
||||
*,
|
||||
public_key_hex: str,
|
||||
generated_at: int,
|
||||
window_hours: int = 24,
|
||||
interval_minutes: int = 60,
|
||||
model_limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
_ = (public_key_hex, generated_at, window_hours, interval_minutes, model_limit)
|
||||
return {
|
||||
"schema": analytics.ANALYTICS_SCHEMA,
|
||||
"generated_at": generated_at,
|
||||
"provider_id": provider_id,
|
||||
"summary": {"total_requests": 1},
|
||||
}
|
||||
|
||||
async def fake_publish(relay_url: str, event: dict[str, Any]) -> bool:
|
||||
_ = relay_url
|
||||
published_events.append(event)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", True)
|
||||
monkeypatch.setattr(analytics.settings, "nsec", "11" * 32)
|
||||
monkeypatch.setattr(analytics.settings, "relays", ["wss://relay.example.com"])
|
||||
monkeypatch.setattr(analytics.settings, "provider_id", "")
|
||||
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fake_build_payload)
|
||||
monkeypatch.setattr(analytics, "publish_to_relay", fake_publish)
|
||||
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
|
||||
|
||||
await analytics.publish_usage_analytics()
|
||||
|
||||
assert len(published_events) == 1
|
||||
assert ["schema", analytics.ANALYTICS_SCHEMA] in published_events[0].get("tags", [])
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import text
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.settings import SettingsService
|
||||
@@ -11,6 +12,7 @@ from routstr.core.settings import SettingsService
|
||||
async def test_settings_seed_from_env_and_persist() -> None:
|
||||
os.environ["UPSTREAM_BASE_URL"] = "https://api.test/v1"
|
||||
os.environ.pop("ONION_URL", None)
|
||||
os.environ.pop("ENABLE_ANALYTICS_SHARING", None)
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
@@ -19,19 +21,53 @@ async def test_settings_seed_from_env_and_persist() -> None:
|
||||
assert settings.upstream_base_url == "https://api.test/v1"
|
||||
# ONION_URL may be empty if not discoverable
|
||||
assert isinstance(settings.onion_url, str)
|
||||
assert settings.enable_analytics_sharing is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_db_precedence_over_env() -> None:
|
||||
os.environ["UPSTREAM_BASE_URL"] = "https://api.env/v1"
|
||||
os.environ["ENABLE_ANALYTICS_SHARING"] = "true"
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
_ = await SettingsService.initialize(session)
|
||||
updated = await SettingsService.update({"name": "DBName"}, session)
|
||||
updated = await SettingsService.update(
|
||||
{"name": "DBName", "enable_analytics_sharing": False}, session
|
||||
)
|
||||
assert updated.name == "DBName"
|
||||
assert updated.enable_analytics_sharing is False
|
||||
|
||||
# Change env and re-initialize; DB should still win
|
||||
os.environ["NAME"] = "EnvName"
|
||||
os.environ["ENABLE_ANALYTICS_SHARING"] = "true"
|
||||
again = await SettingsService.initialize(session)
|
||||
assert again.name == "DBName"
|
||||
assert again.enable_analytics_sharing is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_initialize_discards_unknown_keys() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
_ = await SettingsService.initialize(session)
|
||||
|
||||
# Simulate older persisted key name and an unknown key.
|
||||
await session.exec( # type: ignore
|
||||
text(
|
||||
"UPDATE settings SET data = :data WHERE id = 1"
|
||||
).bindparams(
|
||||
data='{"name":"LegacyNode","nostr_analytics_enabled":false,"unknown_key":123}'
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
reloaded = await SettingsService.initialize(session)
|
||||
assert reloaded.name == "LegacyNode"
|
||||
assert reloaded.enable_analytics_sharing is True
|
||||
|
||||
row = await session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
|
||||
stored_data = row.first()[0]
|
||||
assert '"enable_analytics_sharing": true' in stored_data
|
||||
assert "nostr_analytics_enabled" not in stored_data
|
||||
assert "unknown_key" not in stored_data
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -108,6 +108,92 @@ async def test_credit_balance() -> None:
|
||||
assert mock_session.refresh.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
|
||||
"""Token amount is less than melt_quote.amount + melt_quote.fee_reserve."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = "http://foreign:3338"
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = 404
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.proofs = [{"amount": 404}]
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
mock_primary_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_mint_quote = Mock()
|
||||
mock_mint_quote.quote = "mint_quote_123"
|
||||
mock_mint_quote.request = "lnbc1..."
|
||||
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
|
||||
|
||||
mock_melt_quote = Mock()
|
||||
mock_melt_quote.quote = "melt_quote_123"
|
||||
mock_melt_quote.amount = 400
|
||||
mock_melt_quote.fee_reserve = 12 # total needed: 412 > 404
|
||||
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(ValueError, match="insufficient to cover melt fees"):
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
# melt should never have been called
|
||||
mock_token_wallet.melt.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_to_primary_mint_melt_error_wrapped() -> None:
|
||||
"""Melt failure from cashu lib is wrapped as ValueError."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = "http://foreign:3338"
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = 5000
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.proofs = [{"amount": 5000}]
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
mock_primary_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_mint_quote = Mock()
|
||||
mock_mint_quote.quote = "mint_quote_456"
|
||||
mock_mint_quote.request = "lnbc1..."
|
||||
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
|
||||
|
||||
mock_melt_quote = Mock()
|
||||
mock_melt_quote.quote = "melt_quote_456"
|
||||
mock_melt_quote.amount = 4940
|
||||
mock_melt_quote.fee_reserve = 50 # total 4990 < 5000, passes fee check
|
||||
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
|
||||
mock_token_wallet.melt = AsyncMock(
|
||||
side_effect=Exception("Provided: 5000, needed: 5100 (Code: 11000)")
|
||||
)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(ValueError, match="Failed to melt token"):
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recieve_token_untrusted_mint() -> None:
|
||||
mock_wallet = Mock()
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next",
|
||||
"next/core-web-vitals",
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"plugins": ["react", "@typescript-eslint"]
|
||||
}
|
||||
@@ -42,3 +42,4 @@ next-env.d.ts
|
||||
|
||||
# favicon conflicts
|
||||
/app/favicon.ico
|
||||
.pnpm-store/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pnpm-lock.yaml
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
next-env.d.ts
|
||||
+21
-35
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+138
-178
@@ -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;
|
||||
}
|
||||
|
||||
+3
-16
@@ -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>
|
||||
|
||||
+38
-53
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+122
-429
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+121
-115
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-327
@@ -1,329 +1,5 @@
|
||||
'use client';
|
||||
import { ModelsPage } from '@/components/models-page';
|
||||
|
||||
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>
|
||||
);
|
||||
export default function ModelPage() {
|
||||
return <ModelsPage />;
|
||||
}
|
||||
|
||||
+986
-307
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -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>
|
||||
|
||||
+290
-1090
File diff suppressed because it is too large
Load Diff
+22
-29
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+327
-314
@@ -1,20 +1,27 @@
|
||||
'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 { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -23,331 +30,337 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
Check,
|
||||
Receipt,
|
||||
} from 'lucide-react';
|
||||
import { AdminService, type Transaction } from '@/lib/api/services/admin';
|
||||
import { format } from 'date-fns';
|
||||
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');
|
||||
}
|
||||
},
|
||||
};
|
||||
const STORAGE_KEY = 'routstr-transaction-filters';
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const perPage = 20;
|
||||
const [search, setSearch] = useState('');
|
||||
const [type, setType] = useState<string>('all');
|
||||
const [status, setStatus] = useState<string>('all');
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
// Fetch paginated transactions data
|
||||
const {
|
||||
data: paginationData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['transactions', currentPage, perPage],
|
||||
// Load filters from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.search) setSearch(parsed.search);
|
||||
if (parsed.type) setType(parsed.type);
|
||||
if (parsed.status) setStatus(parsed.status);
|
||||
} catch (e) {
|
||||
console.error('Failed to load filters from localStorage', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save filters to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
const filters = { search, type, status };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
||||
}, [search, type, status]);
|
||||
|
||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['transactions', type, status, search],
|
||||
queryFn: () =>
|
||||
TransactionService.getPaginatedTransactions(currentPage, perPage),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30000, // 30 seconds
|
||||
AdminService.getTransactions(
|
||||
type === 'all' ? undefined : type,
|
||||
status === 'all' ? undefined : status,
|
||||
search || undefined,
|
||||
100
|
||||
),
|
||||
});
|
||||
|
||||
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 handleClearFilters = () => {
|
||||
setSearch('');
|
||||
setType('all');
|
||||
setStatus('all');
|
||||
};
|
||||
|
||||
const formatAmount = (amount: string) => {
|
||||
return `${parseInt(amount).toLocaleString()} msats`;
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const truncateToken = (token: string) => {
|
||||
if (token.length <= 20) return token;
|
||||
return `${token.slice(0, 10)}...${token.slice(-10)}`;
|
||||
const getStatusBadge = (tx: Transaction) => {
|
||||
if (tx.swept)
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
|
||||
>
|
||||
Swept
|
||||
</Badge>
|
||||
);
|
||||
if (tx.collected)
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-green-500/20 bg-green-500/10 text-green-500'
|
||||
>
|
||||
Collected
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
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 hasActiveFilters =
|
||||
type !== 'all' || status !== 'all' || Boolean(search);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
const activeFilterDescription = [
|
||||
type !== 'all' ? `type ${type === 'in' ? 'incoming' : 'outgoing'}` : null,
|
||||
status !== 'all' ? `status ${status}` : null,
|
||||
search ? `search "${search}"` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
|
||||
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' : ''}`}
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='X-Cashu Transactions'
|
||||
description='View all incoming and outgoing X-Cashu token transactions.'
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>
|
||||
Filter transactions by type, status, or search text
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='search'>Search</Label>
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
id='search'
|
||||
placeholder='Search by ID, token or request ID...'
|
||||
className='pl-8'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
Refresh
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='type'>Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Type' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Types</SelectItem>
|
||||
<SelectItem value='in'>Incoming (Payments)</SelectItem>
|
||||
<SelectItem value='out'>Outgoing (Refunds)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='status'>Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Status' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Statuses</SelectItem>
|
||||
<SelectItem value='pending'>Pending</SelectItem>
|
||||
<SelectItem value='collected'>Collected</SelectItem>
|
||||
<SelectItem value='swept'>Swept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
|
||||
<Button
|
||||
onClick={handleClearFilters}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{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>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>Transaction History</CardTitle>
|
||||
{data && (
|
||||
<Badge variant='secondary'>
|
||||
{data.transactions.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Showing transactions filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{isLoading ? (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`tx-loading-${index}`}
|
||||
className='h-16 w-full rounded-lg'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : data?.transactions && data.transactions.length > 0 ? (
|
||||
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Request ID</TableHead>
|
||||
<TableHead>Mint</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className='text-right'>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.transactions.map((tx) => (
|
||||
<TableRow key={tx.id}>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{tx.type === 'in' ? (
|
||||
<ArrowDownLeft className='h-4 w-4 text-green-500' />
|
||||
) : (
|
||||
<ArrowUpRight className='h-4 w-4 text-blue-500' />
|
||||
)}
|
||||
<span className='capitalize'>{tx.type}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='font-mono'>
|
||||
{tx.amount} {tx.unit}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(tx)}</TableCell>
|
||||
<TableCell>
|
||||
{tx.request_id ? (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<span className='max-w-[150px] truncate font-mono'>
|
||||
{tx.request_id}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4'
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
tx.request_id!,
|
||||
tx.id + '-req'
|
||||
)
|
||||
}
|
||||
>
|
||||
{copiedId === tx.id + '-req' ? (
|
||||
<Check className='h-3 w-3' />
|
||||
) : (
|
||||
<Copy className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
|
||||
<span className='truncate'>{tx.mint_url}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-xs whitespace-nowrap'>
|
||||
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() =>
|
||||
copyToClipboard(tx.token, tx.id + '-token')
|
||||
}
|
||||
title='Copy Token'
|
||||
>
|
||||
{copiedId === tx.id + '-token' ? (
|
||||
<Check className='h-4 w-4' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<Empty className='py-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Receipt className='h-4 w-4' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No transactions found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Try adjusting your filters or check back later.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-3
@@ -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: '',
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,353 @@
|
||||
'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,
|
||||
ArrowRightLeftIcon,
|
||||
} 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: '/model', icon: DatabaseIcon },
|
||||
{ title: 'Providers', url: '/providers', icon: ServerIcon },
|
||||
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
|
||||
{ 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,23 +2,28 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
DatabaseIcon,
|
||||
LayoutDashboardIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
WalletIcon,
|
||||
ArrowRightLeftIcon,
|
||||
} 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 = {
|
||||
@@ -41,6 +46,11 @@ const data = {
|
||||
url: '/balances',
|
||||
icon: WalletIcon,
|
||||
},
|
||||
{
|
||||
title: 'Transactions',
|
||||
url: '/transactions',
|
||||
icon: ArrowRightLeftIcon,
|
||||
},
|
||||
{
|
||||
title: 'Logs',
|
||||
url: '/logs',
|
||||
@@ -68,29 +78,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user