Compare commits

...

90 Commits

Author SHA1 Message Date
Shroominic
b8c34afef8 improve logging 2025-12-11 10:50:50 +08:00
shroominic
2aee75e7d5 Merge pull request #255 from Routstr/rm-shaky
Rm shaky test
2025-12-11 10:12:33 +08:00
Shroominic
4d67af51ab rm shaky test 2025-12-11 10:08:45 +08:00
shroominic
2f841dfcbc Merge pull request #254 from Routstr/format
make format required & fix ui formating
2025-12-11 10:01:38 +08:00
9qeklajc
7ca69063bd make format required & fix ui formating 2025-12-10 22:51:12 +01:00
Shroominic
02ca36141e fix linting typing/errors 2025-12-09 16:12:41 +08:00
Shroominic
20bf5e35a4 feat: add custom models and overrides 2025-12-09 15:59:07 +08:00
Shroominic
5a67e6b4d6 ignore openrouter/bodybuilder 2025-12-09 15:39:04 +08:00
Shroominic
34d1e4b041 populate upstream_provider_id 2025-12-09 15:03:16 +08:00
Shroominic
b88ef858ee ignore ppq auto model 2025-12-09 14:38:12 +08:00
Shroominic
a363c7a0b1 feat: openrouter balance fetch + generic get_balance 2025-12-09 14:28:07 +08:00
Shroominic
4ac257db0d fix model fetching issue 2025-12-09 13:50:49 +08:00
Shroominic
fd5ec01a99 fix ppq models matching incorrectly 2025-12-09 13:50:32 +08:00
9qeklajc
d6648d3337 Merge pull request #239 from Routstr/gemini-upstream
gemini upstream
2025-12-03 22:29:09 +01:00
9qeklajc
e7f677b315 clean up 2025-12-03 20:26:14 +01:00
9qeklajc
d7611e74c3 remove model prefix for gemini 2025-12-03 20:24:36 +01:00
9qeklajc
d0790dcb22 use openrouter model matching 2025-12-03 19:36:06 +01:00
Shroominic
2b4f71cc4f Merge branch 'v0.2.1' into gemini-upstream 2025-12-02 12:46:52 +08:00
Shroominic
80359d0854 add required openai dep 2025-12-02 12:42:33 +08:00
Shroominic
42c2ddb355 optimize async startup process 2025-12-02 12:34:50 +08:00
Shroominic
65e7702f90 merge ppq integration 2025-12-02 12:32:49 +08:00
shroominic
253f419ada Merge pull request #234 from Routstr/ppq-integration
PPQ.ai provider integration
2025-12-02 12:04:03 +08:00
shroominic
83818e1097 Merge pull request #246 from Routstr/fix-reserved–balance-race-condition
Fix reserved–balance race condition
2025-12-02 11:58:52 +08:00
Shroominic
f116a5ed38 update provider fee label 2025-12-02 11:55:39 +08:00
Shroominic
ee185f56e5 editable provider fee 2025-12-02 11:54:48 +08:00
Shroominic
b76598ea41 fix reserved balance race condition bug 2025-12-01 17:27:58 +08:00
Shroominic
432fe48f36 Merge branch 'v0.2.1' into gemini-upstream 2025-11-28 13:21:56 +09:00
Shroominic
7b6e00edd8 ruff format 2025-11-28 13:21:25 +09:00
shroominic
e59335db4d Merge pull request #233 from GitHappens2Me/fix/logging_redaction
Fixed false positives in SecurityFilter
2025-11-28 13:08:08 +09:00
Shroominic
6f00478580 fix typing issues 2025-11-28 12:59:49 +09:00
Shroominic
4ed4f610f4 fix linting 2025-11-28 12:57:05 +09:00
Shroominic
30fd369bfa Merge branch 'v0.2.1' into fix/logging_redaction 2025-11-28 12:55:18 +09:00
Shroominic
8208a870a2 fix linting errors 2025-11-28 12:54:21 +09:00
9qeklajc
5eb4a40395 fix 2025-11-24 22:24:43 +01:00
9qeklajc
95ffc612ca openai lib 2025-11-24 22:07:28 +01:00
9qeklajc
afa4a59bb6 improve performance 2025-11-24 21:20:40 +01:00
9qeklajc
a67cd2d604 Merge pull request #243 from Routstr/fix-display-when-no-model-exists
display all providers
2025-11-24 21:02:00 +01:00
9qeklajc
d6fdf0c581 display all providers 2025-11-24 21:00:04 +01:00
Shroominic
ed15f61392 removed the unused /_register route 2025-11-23 15:14:18 -08:00
Shroominic
684ab639ac fix auto redirect to /login 2025-11-23 15:00:54 -08:00
shroominic
0d698e9e75 Merge pull request #242 from Routstr/login-dark-mode
make login page dark mode
2025-11-23 14:37:39 -08:00
Shroominic
f5be9878d8 v0.2.1 bump 2025-11-23 14:32:54 -08:00
shroominic
30fc8d91f6 Merge pull request #241 from Routstr/cursor/recreate-branch-with-original-commits-gemini-3-pro-preview-bbe5
unify stats and logs page with original commits
2025-11-23 14:29:58 -08:00
Cursor Agent
f7d6a0e349 Merge branch 'origin/v0.2.1' into cursor/recreate-branch-with-original-commits-gemini-3-pro-preview-bbe5 2025-11-23 22:23:00 +00:00
Shroominic
2cbf7b41f3 fix build error 2025-11-23 14:08:07 -08:00
Shroominic
d142ca52c7 fix date picker 2025-11-23 21:51:36 +00:00
Shroominic
c5b448b1ff fix search 2025-11-23 21:51:36 +00:00
Shroominic
6f4e57eeff feat(ui): apply global currency formatting to stats components
- Update UsageSummaryCards to use global currency store and formatting
- Update RevenueByModelTable to use global currency store and formatting
- Ensure consistent sat/msat/usd display across all dashboard metrics
2025-11-23 21:51:36 +00:00
Shroominic
3371b03068 feat(ui): update currency toggle to minimalistic dropdown
- Replace toggle group with dropdown menu for currency selection
- Update currency formatting to round USD to 2 decimals
- Improve header space utilization
2025-11-23 21:51:36 +00:00
Shroominic
64b45a75c7 feat(ui): implement global currency selection
- Add global currency store (sat/msat/usd)
- Add currency toggle to site header
- Update currency formatting logic (integers for sats, 2 decimals for USD)
- Connect dashboard and balances pages to global currency state
2025-11-23 21:51:36 +00:00
Shroominic
dcfd398008 feat(ui): add balance summary to main dashboard
- Create DashboardBalanceSummary component for quick balance overview
- Add balance summary cards to top of main dashboard
- Separate current balance view from time-filtered usage analytics
2025-11-23 21:51:36 +00:00
Shroominic
85962aa45a refactor(ui): restructure dashboard layout
- Move wallet balances to dedicated /balances page
- Promote usage statistics to main dashboard (/)
- Update sidebar navigation
- Update backend routes for new page structure
- Remove old usage page
2025-11-23 21:51:36 +00:00
Shroominic
d0aa91aa51 feat(ui): enhance dashboard visualizations
- Replace usage metrics line charts with gradient area charts
- Redesign summary cards with improved styling and icons
- Add revenue share progress bar to models table
- Format numbers in tables for better readability
2025-11-23 21:51:36 +00:00
Shroominic
f13638387f fix provider getattr 2025-11-23 21:51:36 +00:00
Shroominic
e154f65e16 fix revenue tracking problems 2025-11-23 21:51:36 +00:00
Shroominic
86b1ba0228 fix typing+linting 2025-11-23 21:51:36 +00:00
Cursor Agent
eb83b3c51a refactor: unify logs and usage implementation 2025-11-23 21:51:36 +00:00
Shroominic
2438f3231f fix docker build 2025-11-23 21:51:16 +00:00
Cursor Agent
13640d13d1 Merge remote-tracking branch 'remotes/origin/cursor/add-usage-tracking-dashboard-and-apis-32d6' into recreated-feature-branch 2025-11-23 21:51:16 +00:00
Shroominic
ec60dbe568 half undo 83a49a3 to not introduce extra db-settings 2025-11-23 12:29:00 -08:00
9qeklajc
12515cb0e3 clean up 2025-11-22 21:00:58 +01:00
9qeklajc
0f23335de9 first shot 2025-11-22 20:07:26 +01:00
shroominic
edd7bd40c5 Merge pull request #224 from GitHappens2Me/minor-fixes
Minor spelling / formatting fixes
2025-11-21 21:27:18 -08:00
Shroominic
dacabaa5d4 fix pytests 2025-11-21 18:06:48 -08:00
shroominic
6d6f66d6d0 Merge pull request #236 from Routstr/node-landingpage-clean
api cheat sheet
2025-11-21 17:57:09 -08:00
shroominic
8a70e19430 Merge pull request #231 from GitHappens2Me/fix/stream-encoding
fixed streaming responses for unsupported content-encoding
2025-11-21 17:49:02 -08:00
shroominic
19575eb1b9 Merge branch 'v0.2.1' into minor-fixes 2025-11-21 17:47:37 -08:00
shroominic
e71baeb6d6 Merge pull request #232 from Routstr/rm-cdk-python-binaries
rm accidental push of cdk-python binaries
2025-11-21 17:46:39 -08:00
shroominic
83a49a3c4a Merge pull request #202 from Routstr/feature/configurable-model-exclusions
Feature/configurable model exclusions
2025-11-21 17:44:20 -08:00
shroominic
071444f9d0 Merge pull request #200 from Routstr/fix/remove-models-from-base-url-output
Fix/remove models from base url output
2025-11-21 17:43:40 -08:00
Cursor Agent
177ea25723 Merge main into feature/configurable-model-exclusions
- Resolved merge conflicts in .env.example and routstr/payment/models.py
- Kept configurable model exclusions approach from feature branch
- Added new excluded models (sonoma-dusk-alpha, sonoma-sky-alpha) to settings.py defaults
- Updated .env.example to include all excluded models
- Maintained compatibility with main branch changes including UI configuration
2025-11-22 01:31:47 +00:00
Shroominic
028e73951e make login page dark mode 2025-11-20 17:53:53 -08:00
Shroominic
1a2b52ab90 fix ui build 2025-11-20 17:42:37 -08:00
Shroominic
f45ff16674 api cheat sheet 2025-11-20 15:58:44 -08:00
GitHappens2Me
c5c032bd2c fixed false positive redactions and added unit tests 2025-11-18 18:58:26 +01:00
Shroominic
0bcf7bb948 rm accidental push of cdk-python binaries 2025-11-17 12:26:00 -08:00
GitHappens2Me
dbffef62e6 fixed streaming responses for unsupported content-encoding 2025-11-17 19:08:25 +01:00
9qeklajc
38356d7bb3 add copy button & order entries 2025-11-16 10:46:10 +01:00
Cursor Agent
99d98ffb2c feat: Add usage tracking dashboard and API
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-11-15 23:49:36 +00:00
9qeklajc
26110a68dd add logs page 2025-11-15 15:59:08 +01:00
9qeklajc
23ff99d41f copy button 2025-11-15 12:53:58 +01:00
9qeklajc
3e7d4c6e86 add date picker 2025-11-15 11:46:24 +01:00
9qeklajc
34cdbfe446 add logs page 2025-11-15 11:32:18 +01:00
GitHappens2Me
a5ac510cd0 Merge remote-tracking branch 'upstream/main' into minor-fixes 2025-11-11 15:50:19 +01:00
GitHappens2Me
fe66f249f0 small fixes 2025-11-11 15:42:26 +01:00
redshift
fe2b1846f7 Add EXCLUDED_MODEL_IDS configuration to .env.example
- Document the new EXCLUDED_MODEL_IDS environment variable
- Show example with default excluded model IDs
- Maintain existing configuration structure
2025-10-17 04:12:05 +00:00
redshift
859b31e2bc Update tests to reflect removal of models field from base URL output
- Remove models field from required fields list in root endpoint test
- Add explicit test to ensure models field is not present
- Update test comments to reference issue #184

Related to #184
2025-10-17 04:11:48 +00:00
redshift
80a7f5d2eb Replace hardcoded model exclusions with configurable setting
- Remove hardcoded model ID checks for openrouter/auto and google/gemini-2.5-pro-exp-03-25
- Use settings.excluded_model_ids list for model filtering
- Maintain backward compatibility with existing exclusion logic
- Add proper error handling for settings access
2025-10-17 04:11:46 +00:00
redshift
fa0b2834c5 Remove models variable from base URL output
- Remove the deprecated models field from /v1/info endpoint
- The models field was kept for back-compatibility but is now removed
- Users should use the dedicated /v1/models endpoint instead

Fixes #184
2025-10-17 04:10:30 +00:00
redshift
d693b559c0 Add configurable excluded model IDs setting
- Add excluded_model_ids field to Settings class
- Support comma-separated list via EXCLUDED_MODEL_IDS env var
- Include default exclusions for openrouter/auto and google/gemini-2.5-pro-exp-03-25
2025-10-17 04:10:25 +00:00
59 changed files with 5837 additions and 604 deletions

View File

@@ -8,4 +8,6 @@ compose.testing.yml
.todo
.github
.vscode
.DS_Store
.DS_Store
**/node_modules
ui/.next

View File

@@ -62,14 +62,18 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
node-version: "18"
cache: "npm"
cache-dependency-path: ui/package-lock.json
- name: Install UI dependencies
working-directory: ./ui
run: npm ci
- name: Run UI format check
working-directory: ./ui
run: npm run format-check
- name: Run UI linting
working-directory: ./ui
run: npm run lint

View File

@@ -162,7 +162,7 @@ Once built, the UI is automatically served by the FastAPI backend:
- **Dashboard**: `http://localhost:8000/`
- **Login**: `http://localhost:8000/login`
- **Models Management**: `http://localhost:8000/model
- **Models Management**: `http://localhost:8000/model`
- **Providers Management**: `http://localhost:8000/providers`
- **Settings**: `http://localhost:8000/settings`

View File

@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.2.0c"
version = "0.2.1"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
@@ -20,6 +20,7 @@ dependencies = [
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
]
[dependency-groups]

View File

@@ -150,18 +150,6 @@ def should_prefer_model(
# Prefer lower adjusted cost
should_replace = candidate_adjusted < current_adjusted
# Log provider changes when candidate wins
if should_replace:
candidate_provider_name = getattr(
candidate_provider, "upstream_name", "unknown"
)
current_provider_name = getattr(current_provider, "upstream_name", "unknown")
logger.debug(
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
f"(cost: ${current_adjusted:.6f})"
)
return should_replace
@@ -254,7 +242,12 @@ def create_model_mappings(
# Add to unique models
base_id = get_base_model_id(model_to_use.id)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(update={"id": base_id})
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream.provider_type,
}
)
unique_models[base_id] = unique_model
# Get all aliases for this model

View File

@@ -9,7 +9,7 @@ from sqlmodel import col, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
from .core.settings import settings
from .payment.cost_caculation import (
from .payment.cost_calculation import (
CostData,
CostDataError,
MaxCostData,
@@ -337,7 +337,7 @@ async def pay_for_request(
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) >= cost_per_request)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
total_requests=col(ApiKey.total_requests) + 1,

View File

@@ -3,7 +3,7 @@ import secrets
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from sqlmodel import select
@@ -18,6 +18,7 @@ from ..wallet import (
slow_filter_spend_proofs,
)
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
from .log_manager import log_manager
from .logging import get_logger
from .settings import SettingsService, settings
@@ -3045,3 +3046,122 @@ h1 { color: #333; }
.no-logs { text-align: center; color: #666; padding: 40px; }
.request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; }
"""
@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)])
async def get_usage_metrics(
request: Request,
interval: int = Query(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
) -> dict:
"""Get usage metrics aggregated by time interval."""
return log_manager.get_usage_metrics(interval=interval, hours=hours)
@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"
),
) -> dict:
"""Get summary statistics for the specified time period."""
return log_manager.get_usage_summary(hours=hours)
@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)])
async def get_error_details(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
),
) -> dict:
"""Get detailed error information."""
return log_manager.get_error_details(hours=hours, limit=limit)
@admin_router.get(
"/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)]
)
async def get_revenue_by_model(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
),
) -> dict:
"""
Get revenue breakdown by model.
"""
return log_manager.get_revenue_by_model(hours=hours, limit=limit)
@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)])
async def get_logs_api(
request: Request,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search: str | None = None,
limit: int = 100,
) -> dict[str, object]:
"""
Get filtered log entries.
Args:
date: Filter by specific date (YYYY-MM-DD)
level: Filter by log level
request_id: Filter by request ID
search: Search text in message and name fields (case-insensitive)
limit: Maximum number of entries to return
Returns:
Dict containing logs and filter metadata
"""
log_entries = log_manager.search_logs(
date=date,
level=level,
request_id=request_id,
search_text=search,
limit=limit,
)
return {
"logs": log_entries,
"total": len(log_entries),
"date": date,
"level": level,
"request_id": request_id,
"search": search,
"limit": limit,
}
@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)])
async def get_log_dates_api(request: Request) -> dict[str, object]:
logs_dir = Path("logs")
dates = []
if logs_dir.exists():
log_files = sorted(
logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True
)
for log_file in log_files[:30]:
try:
filename = log_file.name
date_str = filename.replace("app_", "").replace(".log", "")
dates.append(date_str)
except Exception:
continue
return {"dates": dates}

View File

@@ -126,8 +126,6 @@ def run_migrations() -> None:
import pathlib
try:
logger.info("Starting database migrations")
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"
@@ -144,7 +142,6 @@ def run_migrations() -> None:
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
logger.info("Running migrations to latest revision")
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")

469
routstr/core/log_manager.py Normal file
View File

@@ -0,0 +1,469 @@
import json
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator
from .logging import get_logger
logger = get_logger(__name__)
class LogManager:
def __init__(self, logs_dir: Path = Path("logs")):
self.logs_dir = logs_dir
def _yield_log_entries(
self,
hours_back: int | None = None,
specific_date: str | None = None,
reverse_files: bool = False,
max_files: int | None = None,
) -> Iterator[dict[str, Any]]:
"""
Yields log entries from files.
Args:
hours_back: specific number of hours to look back.
specific_date: specific date string (YYYY-MM-DD) to look at.
reverse_files: if True, process files in reverse order (newest first).
max_files: maximum number of log files to process (most recent if reverse_files is True).
"""
if not self.logs_dir.exists():
return
log_files = []
cutoff_date = None
if specific_date:
log_file = self.logs_dir / f"app_{specific_date}.log"
if log_file.exists():
log_files.append(log_file)
else:
log_files = sorted(self.logs_dir.glob("app_*.log"))
if reverse_files:
log_files.reverse()
# If we only care about hours back, we can optimize file selection
if hours_back is not None:
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
filtered_files = []
for log_path in log_files:
try:
file_date_str = log_path.stem.split("_")[1]
file_date = datetime.strptime(
file_date_str, "%Y-%m-%d"
).replace(tzinfo=timezone.utc)
# Include file if it's from the same day or after the cutoff day
if file_date >= cutoff_date.replace(
hour=0, minute=0, second=0, microsecond=0
):
filtered_files.append(log_path)
except Exception:
continue
log_files = filtered_files
if max_files is not None and len(log_files) > max_files:
log_files = log_files[:max_files]
for log_file in log_files:
try:
with open(log_file, "r") as f:
# For reverse search, we might want to read lines in reverse?
# But usually logs are append-only.
# If reverse_files is True, we iterate files newest to oldest.
# But lines within file are still oldest to newest unless we reverse them.
lines = f.readlines()
if reverse_files:
lines.reverse()
for line in lines:
try:
entry = json.loads(line.strip())
if cutoff_date:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(
timestamp_str, "%Y-%m-%d %H:%M:%S"
)
log_time = log_time.replace(tzinfo=timezone.utc)
if log_time < cutoff_date:
continue
yield entry
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"Error processing log file {log_file}: {e}")
continue
def search_logs(
self,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
Search through log files and return matching entries.
"""
log_entries: list[dict[str, Any]] = []
# Use reverse=True to get newest logs first by default
# If date is specified, we only look at that file
search_text_lower = search_text.lower() if search_text else None
# We iterate efficiently
iterator = self._yield_log_entries(
specific_date=date,
reverse_files=True if not date else False,
max_files=7 if not date else None,
)
# If we are searching globally (no date), we might want to limit how far back we go?
# PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files)
# My _yield_log_entries with reverse_files=True does all files.
# Let's rely on limit to stop us.
# Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield?
# For now, let's just iterate.
for log_data in iterator:
if not self._matches_filters(
log_data, level, request_id, search_text_lower
):
continue
log_entries.append(log_data)
if len(log_entries) >= limit:
break
# Sort by time descending (newest first)
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
return log_entries
def _matches_filters(
self,
log_data: dict[str, Any],
level: str | None,
request_id: str | None,
search_text_lower: str | None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
if request_id and log_data.get("request_id") != request_id:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
pathname = str(log_data.get("pathname", "")).lower()
if (
search_text_lower not in message
and search_text_lower not in name
and search_text_lower not in pathname
):
return False
return True
def get_usage_summary(self, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._calculate_summary_stats(entries)
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._aggregate_metrics_by_time(entries, interval, hours)
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
errors: list[dict] = []
# Iterate newest to oldest for errors?
# yield_log_entries sorts files by name (date) ascending by default.
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
# For error details "last N errors", we probably want newest first.
# Using list() loads everything into memory, which is what PR 229 did.
# For optimization, we could use reverse iterator.
# Let's just stick to PR 229 logic which filters 'ERROR' level.
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
for entry in entries:
if entry.get("levelname", "").upper() == "ERROR":
timestamp_str = entry.get("asctime", "")
errors.append(
{
"timestamp": timestamp_str,
"message": entry.get("message", ""),
"error_type": entry.get("error_type", "unknown"),
"pathname": entry.get("pathname", ""),
"lineno": entry.get("lineno", 0),
"request_id": entry.get("request_id", ""),
}
)
# Sort reverse time
errors.sort(key=lambda x: x["timestamp"], reverse=True)
return {"errors": errors[:limit], "total_count": len(errors)}
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
model_stats: dict[str, dict[str, int | float]] = defaultdict(
lambda: {
"revenue_msats": 0,
"refunds_msats": 0,
"requests": 0,
"successful": 0,
"failed": 0,
}
)
for entry in entries:
try:
model = entry.get("model", "unknown")
if not isinstance(model, str):
model = "unknown"
message = entry.get("message", "").lower()
if "received proxy request" in message:
model_stats[model]["requests"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
model_stats[model]["successful"] += 1
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
model_stats[model]["revenue_msats"] += actual_cost
if "revert payment" in message or "upstream request failed" in message:
model_stats[model]["failed"] += 1
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
model_stats[model]["refunds_msats"] += max_cost
except Exception:
continue
models: list[dict[str, Any]] = []
total_revenue = 0.0
for model, stats in model_stats.items():
revenue_msats = float(stats["revenue_msats"])
refunds_msats = float(stats["refunds_msats"])
revenue_sats = revenue_msats / 1000
refunds_sats = refunds_msats / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_revenue += net_revenue_sats
requests = int(stats["requests"])
successful = int(stats["successful"])
models.append(
{
"model": model,
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_sats": net_revenue_sats,
"requests": requests,
"successful": successful,
"failed": int(stats["failed"]),
"avg_revenue_per_request": (
revenue_sats / successful if successful > 0 else 0
),
}
)
models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True)
return {
"models": models[:limit],
"total_revenue_sats": total_revenue,
"total_models": len(models),
}
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
stats: dict[str, Any] = {
"total_entries": 0,
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"total_errors": 0,
"total_warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"unique_models": set(),
"error_types": defaultdict(int),
"revenue_msats": 0.0,
"refunds_msats": 0.0,
}
for entry in entries:
try:
stats["total_entries"] += 1
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if level == "ERROR":
stats["total_errors"] += 1
if "error_type" in entry:
stats["error_types"][str(entry["error_type"])] += 1
elif level == "WARNING":
stats["total_warnings"] += 1
if "received proxy request" in message:
stats["total_requests"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
stats["successful_chat_completions"] += 1
if "upstream request failed" in message or "revert payment" in message:
stats["failed_requests"] += 1
if "payment processed successfully" in message:
stats["payment_processed"] += 1
if "upstream" in message and level == "ERROR":
stats["upstream_errors"] += 1
if "model" in entry:
model = entry["model"]
if isinstance(model, str) and model != "unknown":
stats["unique_models"].add(model)
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
stats["revenue_msats"] += float(actual_cost)
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
stats["refunds_msats"] += float(max_cost)
except Exception:
continue
revenue_sats = stats["revenue_msats"] / 1000
refunds_sats = stats["refunds_msats"] / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_requests = stats["total_requests"]
successful = stats["successful_chat_completions"]
return {
"total_entries": stats["total_entries"],
"total_requests": total_requests,
"successful_chat_completions": successful,
"failed_requests": stats["failed_requests"],
"total_errors": stats["total_errors"],
"total_warnings": stats["total_warnings"],
"payment_processed": stats["payment_processed"],
"upstream_errors": stats["upstream_errors"],
"unique_models_count": len(stats["unique_models"]),
"unique_models": sorted(list(stats["unique_models"])),
"error_types": dict(stats["error_types"]),
"success_rate": (successful / total_requests * 100)
if total_requests > 0
else 0,
"revenue_msats": stats["revenue_msats"],
"refunds_msats": stats["refunds_msats"],
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"],
"net_revenue_sats": net_revenue_sats,
"avg_revenue_per_request_msats": (
stats["revenue_msats"] / successful if successful > 0 else 0
),
"refund_rate": (
(stats["failed_requests"] / total_requests * 100)
if total_requests > 0
else 0
),
}
def _aggregate_metrics_by_time(
self, entries: list[dict], interval_minutes: int, hours_back: int
) -> dict:
time_buckets: dict[str, dict[str, Any]] = defaultdict(
lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0}
)
for entry in entries:
try:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
log_time = log_time.replace(tzinfo=timezone.utc)
# Round down to nearest interval
minutes = log_time.minute
rounded_minutes = (minutes // interval_minutes) * interval_minutes
bucket_time = log_time.replace(
minute=rounded_minutes, second=0, microsecond=0
)
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
bucket = time_buckets[bucket_key]
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if "received proxy request" in message:
bucket["requests"] += 1
if level == "ERROR":
bucket["errors"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
bucket["revenue_msats"] += float(actual_cost)
except Exception:
continue
result = []
for bucket_key in sorted(time_buckets.keys()):
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
return {
"metrics": result,
"interval_minutes": interval_minutes,
"hours_back": hours_back,
"total_buckets": len(result),
}
log_manager = LogManager()

View File

@@ -1,3 +1,40 @@
"""
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).
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
- 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
3. "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
- 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
- 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()
"""
import logging.config
import logging.handlers
import os
@@ -155,21 +192,24 @@ class SecurityFilter(logging.Filter):
"""Filter out sensitive information from log records."""
try:
message = record.getMessage()
standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
r"nsec[a-z0-9]+", # Nostr Public / Private Key
]
for pattern in standalone_patterns:
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
for key in self.SENSITIVE_KEYS:
if key in message.lower():
patterns = [
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
key_patterns = [
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
]
for pattern in patterns:
for pattern in key_patterns:
message = re.sub(
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
)
record.msg = message
record.args = ()
@@ -298,6 +338,11 @@ def setup_logging() -> None:
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"openai": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"httpcore": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
@@ -320,6 +365,11 @@ def setup_logging() -> None:
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
"alembic": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
},
"root": {
"level": log_level,

View File

@@ -34,9 +34,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.2.0c-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.2.0c"
__version__ = "0.2.1"
@asynccontextmanager
@@ -54,9 +54,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
try:
# Run database migrations on startup
# This ensures the database schema is always up-to-date in production
# Migrations are idempotent - running them multiple times is safe
logger.info("Running database migrations")
run_migrations()
# Initialize database connection pools
@@ -80,8 +77,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
from ..proxy import get_upstreams
from ..upstream.helpers import refresh_upstreams_models_periodically
await _update_prices()
await initialize_upstreams()
_update_prices_task = asyncio.create_task(_update_prices())
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
@@ -92,11 +89,21 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
nip91_task = asyncio.create_task(announce_provider())
providers_task = asyncio.create_task(providers_cache_refresher())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
yield
except asyncio.CancelledError:
# Expected during shutdown
pass
except Exception as e:
logger.error(
"Application startup failed",
@@ -182,7 +189,6 @@ async def info() -> dict:
"mints": global_settings.cashu_mints,
"http_url": global_settings.http_url,
"onion_url": global_settings.onion_url,
"models": [], # kept for back-compat; prefer /v1/models
}
@@ -264,6 +270,33 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
@app.get("/balances", include_in_schema=False)
async def serve_balances_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
# Add explicit route for /balances/index.txt to redirect to /balances
@app.get("/balances/index.txt", include_in_schema=False)
async def redirect_balances_index_txt() -> RedirectResponse:
return RedirectResponse("/balances")
@app.get("/logs", include_in_schema=False)
async def serve_logs_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
# Add explicit route for /logs/index.txt to redirect to /logs
@app.get("/logs/index.txt", include_in_schema=False)
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@app.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
# Add explicit route for /usage/index.txt to redirect to /usage
@app.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@app.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")

View File

@@ -114,7 +114,7 @@ def resolve_bootstrap() -> Settings:
)
except Exception:
pass
# Map COST_PER_1K_* -> CUSTOM_PER_1K_*
# Map COST_PER_1K_* -> FIXED_PER_1K_*
if (
"COST_PER_1K_INPUT_TOKENS" in os.environ
and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ

View File

@@ -1,4 +1,4 @@
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
__all__ = [
"CostData",

View File

@@ -2,6 +2,7 @@ import asyncio
import json
import random
from pathlib import Path
from typing import Final
from urllib.request import urlopen
import httpx
@@ -19,6 +20,15 @@ logger = get_logger(__name__)
models_router = APIRouter()
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
"openrouter/auto",
"openrouter/bodybuilder",
"google/gemini-2.5-pro-exp-03-25",
"opengvlab/internvl3-78b",
"openrouter/sonoma-dusk-alpha",
"openrouter/sonoma-sky-alpha",
}
class Architecture(BaseModel):
modality: str
@@ -31,10 +41,10 @@ class Architecture(BaseModel):
class Pricing(BaseModel):
prompt: float
completion: float
request: float
image: float
web_search: float
internal_reasoning: float
request: float = 0.0
image: float = 0.0
web_search: float = 0.0
internal_reasoning: float = 0.0
max_prompt_cost: float = 0.0 # in sats not msats
max_completion_cost: float = 0.0 # in sats not msats
max_cost: float = 0.0 # in sats not msats
@@ -58,7 +68,7 @@ class Model(BaseModel):
per_request_limits: dict | None = None
top_provider: TopProvider | None = None
enabled: bool = True
upstream_provider_id: int | None = None
upstream_provider_id: int | str | None = None
canonical_slug: str | None = None
alias_ids: list[str] | None = None
@@ -89,11 +99,7 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
if (
"(free)" in model.get("name", "")
or model_id == "openrouter/auto"
or model_id == "google/gemini-2.5-pro-exp-03-25"
or model_id == "opengvlab/internvl3-78b"
or model_id == "openrouter/sonoma-dusk-alpha"
or model_id == "openrouter/sonoma-sky-alpha"
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
continue
@@ -130,11 +136,7 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
if (
"(free)" in model.get("name", "")
or model_id == "openrouter/auto"
or model_id == "google/gemini-2.5-pro-exp-03-25"
or model_id == "opengvlab/internvl3-78b"
or model_id == "openrouter/sonoma-dusk-alpha"
or model_id == "openrouter/sonoma-sky-alpha"
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
continue

View File

@@ -110,10 +110,6 @@ async def _update_prices() -> None:
return
BTC_USD_PRICE = btc_price
SATS_USD_PRICE = btc_price / 100_000_000
logger.info(
"Updated BTC/USD price",
extra={"btc_usd": btc_price, "sats_usd": SATS_USD_PRICE},
)
def btc_usd_price() -> float:

View File

@@ -2,6 +2,7 @@ from .anthropic import AnthropicUpstreamProvider
from .azure import AzureUpstreamProvider
from .base import BaseUpstreamProvider
from .fireworks import FireworksUpstreamProvider
from .gemini import GeminiUpstreamProvider
from .generic import GenericUpstreamProvider
from .groq import GroqUpstreamProvider
from .ollama import OllamaUpstreamProvider
@@ -15,6 +16,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
AnthropicUpstreamProvider,
AzureUpstreamProvider,
FireworksUpstreamProvider,
GeminiUpstreamProvider,
GenericUpstreamProvider,
GroqUpstreamProvider,
OllamaUpstreamProvider,

View File

@@ -19,7 +19,7 @@ from ..core.db import ApiKey, AsyncSession, create_session
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..payment.cost_caculation import (
from ..payment.cost_calculation import (
CostData,
CostDataError,
MaxCostData,
@@ -151,6 +151,13 @@ class BaseUpstreamProvider:
if headers.pop(auth_header, None) is not None:
removed_headers.append(auth_header)
for header in ["authorization", "accept-encoding"]:
if headers.pop(header, None) is not None:
removed_headers.append(f"{header} (replaced with routstr-safe version)")
# Explicitly define the list of supported compression encodings
headers["accept-encoding"] = "gzip, deflate, br, identity"
logger.debug(
"Headers prepared for upstream",
extra={
@@ -468,11 +475,12 @@ class BaseUpstreamProvider:
)
usage_finalized = True
logger.info(
"Token adjustment completed for streaming",
"Payment adjustment completed for streaming",
extra={
"key_hash": key.hashed_key[:8]
+ "...",
"cost_data": cost_data,
"model": last_model_seen,
"balance_after_adjustment": fresh_key.balance,
},
)
@@ -519,10 +527,15 @@ class BaseUpstreamProvider:
await finalize_without_usage()
raise
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return StreamingResponse(
stream_with_cost(max_cost_for_model),
status_code=response.status_code,
headers=dict(response.headers),
headers=response_headers,
)
async def handle_non_streaming_chat_completion(
@@ -571,7 +584,7 @@ class BaseUpstreamProvider:
response_json["cost"] = cost_data
logger.info(
"Token adjustment completed for non-streaming",
"Payment adjustment completed for non-streaming",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
@@ -1713,7 +1726,6 @@ class BaseUpstreamProvider:
Returns:
List of Model objects with pricing
"""
logger.debug(f"Fetching models for {self.provider_type or self.base_url}")
try:
or_models, provider_models_response = await asyncio.gather(
@@ -1740,18 +1752,9 @@ class BaseUpstreamProvider:
else:
not_found_models.append(model_id)
logger.info(
"Fetched models for provider",
extra={
"provider": self.provider_type or self.base_url,
"found_count": len(found_models),
"not_found_count": len(not_found_models),
},
)
if not_found_models:
logger.debug(
"Models not found in OpenRouter",
f"({len(not_found_models)}/{len(provider_model_ids)}) unmatched models for {self.provider_type or self.base_url}",
extra={"not_found_models": not_found_models},
)
@@ -1819,10 +1822,7 @@ class BaseUpstreamProvider:
self._models_cache = models_with_fees
self._models_by_id = {m.id: m for m in self._models_cache}
logger.info(
f"Refreshed models cache for {self.provider_type or self.base_url}",
extra={"model_count": len(models)},
)
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.provider_type or self.base_url}",
@@ -1891,14 +1891,15 @@ class BaseUpstreamProvider:
f"Provider {self.provider_type} does not support top-up"
)
async def get_balance(self) -> dict[str, object]:
async def get_balance(self) -> float | None:
"""Get the current account balance from the provider.
Returns:
Dict with balance information
Float representing the balance amount, or None if not supported/available.
Typically in USD or the provider's credit unit.
Raises:
NotImplementedError: If provider does not support balance checking
NotImplementedError: If provider does not support balance checking (default behavior)
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support balance checking"

View File

@@ -0,0 +1,3 @@
from .gemini import GeminiClient
__all__ = ["GeminiClient"]

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator
class BaseAPIClient(ABC):
"""Base class for AI provider API clients."""
def __init__(self, api_key: str, base_url: str | None = None):
self.api_key = api_key
self.base_url = base_url
@abstractmethod
async def generate_content(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Generate content non-streaming."""
pass
@abstractmethod
def generate_content_stream(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
pass
@abstractmethod
async def list_models(self) -> list[dict[str, Any]]:
"""List available models."""
pass

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any, AsyncGenerator
from openai import AsyncOpenAI
from .base import BaseAPIClient
class GeminiClient(BaseAPIClient):
"""Gemini API client using OpenAI compatibility layer."""
def __init__(self, api_key: str, base_url: str | None = None):
super().__init__(api_key, base_url)
self.client = AsyncOpenAI(
api_key=api_key,
base_url=base_url
or "https://generativelanguage.googleapis.com/v1beta/openai/",
)
async def generate_content(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
from openai import NOT_GIVEN
response = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
)
return response.model_dump()
async def generate_content_stream(
self,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
from openai import NOT_GIVEN
usage_callback = kwargs.get("usage_callback")
completion_callback = kwargs.get("completion_callback")
stream = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
stream=True,
stream_options={"include_usage": True},
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
)
final_usage = None
async for chunk in stream:
chunk_data = chunk.model_dump()
if chunk.usage:
final_usage = chunk.usage.model_dump()
if usage_callback:
usage_callback(final_usage)
yield chunk_data
if completion_callback:
await completion_callback(model, final_usage)
async def list_models(self) -> list[dict[str, Any]]:
"""List available Gemini models."""
try:
response = await self.client.models.list()
return [model.model_dump() for model in response.data]
except Exception as e:
from ...core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"Failed to list Gemini models: {e}")
return []

283
routstr/upstream/gemini.py Normal file
View File

@@ -0,0 +1,283 @@
from __future__ import annotations
import json
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from .base import BaseUpstreamProvider
from .clients.gemini import GeminiClient
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
logger = get_logger(__name__)
class GeminiUpstreamProvider(BaseUpstreamProvider):
provider_type = "gemini"
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
platform_url = "https://aistudio.google.com/app/apikey"
def __init__(
self,
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
api_key: str = "",
provider_fee: float = 1.01,
):
super().__init__(
api_key=api_key,
provider_fee=provider_fee,
base_url=base_url,
)
self._client: GeminiClient | None = None
@property
def client(self) -> GeminiClient:
"""Get or create the Gemini API client."""
if self._client is None:
self._client = GeminiClient(api_key=self.api_key)
return self._client
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "GeminiUpstreamProvider":
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Google Gemini",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
return model_id.removeprefix("gemini/")
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:
# Remove provider prefix from model ID for Gemini API
if "/" in model_obj.id:
model_obj.id = model_obj.id.split("/", 1)[1]
if not path.startswith("chat/completions"):
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
if not request_body:
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
try:
openai_data = json.loads(request_body)
messages = openai_data.get("messages", [])
temperature = openai_data.get("temperature")
max_tokens = openai_data.get("max_tokens")
top_p = openai_data.get("top_p")
is_streaming = openai_data.get("stream", False)
logger.info(
"Processing Gemini request with client abstraction",
extra={
"model": model_obj.id,
"is_streaming": is_streaming,
"message_count": len(messages),
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming:
final_usage_data: dict | None = None
def usage_callback(usage_data: dict[str, Any]) -> None:
"""Callback to capture usage data during streaming"""
nonlocal final_usage_data
final_usage_data = usage_data
async def completion_callback(
model: str, usage_data: dict[str, Any] | None
) -> None:
"""Callback to handle payment when streaming completes"""
nonlocal final_usage_data
if usage_data:
final_usage_data = usage_data
payment_data = {
"model": model,
"usage": final_usage_data,
}
from ..auth import adjust_payment_for_tokens
from ..core.db import create_session
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
payment_data,
new_session,
max_cost_for_model,
)
logger.info(
"Gemini streaming payment finalized",
extra={
"cost_data": cost_data,
"usage_data": final_usage_data,
"key_hash": key.hashed_key[:8] + "...",
},
)
except Exception as cost_error:
logger.error(
"Error finalizing Gemini streaming payment",
extra={
"error": str(cost_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
response_generator = self.client.generate_content_stream(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
usage_callback=usage_callback,
completion_callback=completion_callback,
)
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
try:
async for chunk in response_generator:
sse_data = f"data: {json.dumps(chunk)}\n\n"
yield sse_data.encode()
except Exception as e:
logger.error(
"Error in Gemini streaming response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
return StreamingResponse(
stream_with_cost(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
else:
openai_format_response = await self.client.generate_content(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
)
from ..auth import adjust_payment_for_tokens
cost_data = await adjust_payment_for_tokens(
key, openai_format_response, session, max_cost_for_model
)
openai_format_response["cost"] = cost_data
logger.info(
"Gemini non-streaming payment completed",
extra={
"cost_data": cost_data,
"model": model_obj.id,
"key_hash": key.hashed_key[:8] + "...",
},
)
return Response(
content=json.dumps(openai_format_response),
media_type="application/json",
headers={"Cache-Control": "no-cache"},
)
except Exception as e:
logger.error(
"Error in Gemini forward_request",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
},
)
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
async def _fetch_provider_models(self) -> dict:
"""Fetch models from Gemini API."""
try:
models_data = await self.client.list_models()
for model in models_data:
if "id" in model and model["id"].startswith("models/"):
model["id"] = model["id"].removeprefix("models/")
return {"data": models_data}
except Exception as e:
logger.error(
f"Failed to fetch models from Gemini API: {e}",
extra={
"error": str(e),
"error_type": type(e).__name__,
"base_url": self.base_url,
},
)
return {"data": []}

View File

@@ -193,7 +193,7 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.info(
logger.debug(
f"Initialized {provider_row.provider_type} provider",
extra={
"base_url": provider_row.base_url,

View File

@@ -1,5 +1,7 @@
from typing import TYPE_CHECKING
import httpx
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
@@ -42,9 +44,33 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
"can_show_balance": True,
}
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.
Returns:
Float representing the balance amount (in credits/USD), or None if unavailable.
"""
url = f"{self.base_url}/credits"
headers = {"Authorization": f"Bearer {self.api_key}"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
credits_data = data.get("data", {})
total_credits = float(credits_data.get("total_credits", 0.0))
total_usage = float(credits_data.get("total_usage", 0.0))
return total_credits - total_usage
except Exception:
return None

View File

@@ -36,6 +36,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
provider_type = "ppqai"
default_base_url = "https://api.ppq.ai"
platform_url = "https://ppq.ai/api-docs"
IGNORED_MODEL_IDS: list[str] = ["auto"]
def __init__(self, api_key: str, provider_fee: float = 1.0):
super().__init__(
@@ -101,11 +102,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
logger.debug(
"Fetching models from PPQ.AI",
extra={"url": url, "has_api_key": bool(self.api_key)},
)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
@@ -113,10 +109,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
data = response.json()
models_data = data.get("data", [])
logger.info(
"Fetched models from PPQ.AI",
extra={"model_count": len(models_data)},
)
or_models = [
Model(**model) # type: ignore
@@ -127,11 +119,16 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
for model_data in models_data:
try:
ppqai_model = PPQAIModel.parse_obj(model_data)
if ppqai_model.id in self.IGNORED_MODEL_IDS:
continue
or_model = next(
(
model
for model in or_models
if model.id == ppqai_model.id
if (model.id == ppqai_model.id)
or (model.id.split("/")[-1] == ppqai_model.id)
or (model.id == ppqai_model.id.split("/")[-1])
),
None,
)
@@ -192,15 +189,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
return models
except httpx.HTTPStatusError as e:
logger.error(
"HTTP error fetching models from PPQ.AI",
extra={
"status_code": e.response.status_code,
"error": str(e),
},
)
return []
except Exception as e:
logger.error(
"Error fetching models from PPQ.AI",
@@ -371,16 +359,20 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
return topup_data
async def get_balance(self) -> dict[str, object]:
async def get_balance(self) -> float | None:
"""Get the current account balance from PPQ.AI.
Returns:
Dict with balance information
Float representing the balance amount (in USD), or None if unavailable.
Raises:
httpx.HTTPStatusError: If the API request fails
"""
return await self.check_balance()
data = await self.check_balance()
balance = data.get("balance")
if isinstance(balance, (int, float)):
return float(balance)
return None
async def check_balance(self) -> dict[str, object]:
"""Check the account balance for this PPQ.AI account.

View File

@@ -62,7 +62,6 @@ async def test_root_endpoint_structure_and_performance(
"mints",
"http_url",
"onion_url",
"models",
]
for field in required_fields:
assert field in data, f"Missing required field: {field}"
@@ -75,15 +74,9 @@ async def test_root_endpoint_structure_and_performance(
assert isinstance(data["mints"], list)
assert isinstance(data["http_url"], str)
assert isinstance(data["onion_url"], str)
assert isinstance(data["models"], list)
# Validate models structure if any exist
for model in data["models"]:
assert isinstance(model, dict)
# Models should have at least basic fields
model_required_fields = ["id", "name"]
for field in model_required_fields:
assert field in model, f"Model missing required field: {field}"
# Ensure models field is not present (removed as per issue #184)
assert "models" not in data, "Models field should not be present in base URL output"
# Verify no database state changes
diff = await db_snapshot.diff()

View File

@@ -142,46 +142,6 @@ class TestPerformanceBaseline:
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
@pytest.mark.asyncio
async def test_database_query_performance(
self, integration_session: Any, db_snapshot: Any
) -> None:
"""Test database operation performance"""
from sqlmodel import select
from routstr.core.db import ApiKey
# Create test data
for i in range(100):
key = ApiKey(
hashed_key=f"test_key_{i}",
balance=1000000,
total_spent=0,
total_requests=0,
)
integration_session.add(key)
await integration_session.commit()
# Test query performance
query_times = []
for _ in range(100):
start = time.time()
result = await integration_session.execute(
select(ApiKey).where(ApiKey.balance > 0) # type: ignore[arg-type]
)
_ = result.all()
duration = (time.time() - start) * 1000
query_times.append(duration)
# All queries should complete < 100ms
assert max(query_times) < 100, (
f"Max query time {max(query_times)}ms exceeds 100ms limit"
)
print("\nDatabase query performance:")
print(f" Mean: {statistics.mean(query_times):.2f}ms")
print(f" Max: {max(query_times):.2f}ms")
@pytest.mark.integration
@pytest.mark.slow

View File

@@ -0,0 +1,118 @@
"""Unit tests for the logging SecurityFilter.
This module tests that the SecurityFilter correctly identifies and redacts
sensitive information from log messages without causing false positives.
"""
import logging
from collections.abc import Callable
import pytest
from routstr.core.logging import SecurityFilter
@pytest.fixture
def security_filter() -> SecurityFilter:
"""Provide an instance of the SecurityFilter for testing."""
return SecurityFilter()
@pytest.fixture
def filter_message(security_filter: SecurityFilter) -> Callable[[str], str]:
"""A helper fixture to apply the filter to a message string."""
def _filter(msg: str) -> str:
record = logging.LogRecord(
name="test_logger",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
security_filter.filter(record)
return record.getMessage()
return _filter
def test_redacts_unquoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
"""Test that an unquoted key-value pair is correctly redacted."""
original = "Processing request with api_key=sk-12345abcdef"
expected = "Processing request with api_key: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_quoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
"""Test that a quoted token is correctly redacted."""
original = 'User authenticated with token="cashuA123abc"'
expected = "User authenticated with token: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_bearer_token(filter_message: Callable[[str], str]) -> None:
"""Test that a Bearer token of sufficient length is redacted."""
original = "Authorization: Bearer abc1234567890xyzabcdefg"
expected = "Authorization: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_cashu_token(filter_message: Callable[[str], str]) -> None:
"""Test that a Cashu token is redacted."""
original = "Received cashuTOKENeyJ0b2tlbiI6W3siaWQiOiI"
expected = "Received [REDACTED]"
assert filter_message(original) == expected
def test_redacts_nsec_key(filter_message: Callable[[str], str]) -> None:
"""Test that a full-length Nostr private key is redacted."""
original = "Private key is nsec1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f"
expected = "Private key is [REDACTED]"
assert filter_message(original) == expected
def test_ignores_non_sensitive_message(filter_message: Callable[[str], str]) -> None:
"""Test that a message with no sensitive data is left untouched."""
original = "No token pricing configured, using base cost"
expected = "No token pricing configured, using base cost"
assert filter_message(original) == expected
def test_multiple_secrets_in_one_message(filter_message: Callable[[str], str]) -> None:
"""Test that multiple different secrets in one message are all redacted."""
original = 'Auth with Bearer abcdefghijklmnopqrstuvwxyz and api_key="sk-12345"'
expected = "Auth with [REDACTED] and api_key: [REDACTED]"
assert filter_message(original) == expected
def test_redacts_key_with_no_value(filter_message: Callable[[str], str]) -> None:
"""Test that a key with no value is not redacted."""
original = "Request contains api_key and secret."
expected = "Request contains api_key and secret."
assert filter_message(original) == expected
def test_redacts_key_value_with_spaces(filter_message: Callable[[str], str]) -> None:
"""Test that key-value pairs with extra spaces are correctly redacted."""
original = "Auth info: api_key = 'sk-12345'"
expected = "Auth info: api_key: [REDACTED]"
assert filter_message(original) == expected
def test_is_case_insensitive_for_keys(filter_message: Callable[[str], str]) -> None:
"""Test that key matching is case-insensitive."""
original = "TOKEN=sk-abcdef12345"
expected = "token: [REDACTED]"
assert filter_message(original) == expected
def test_is_case_insensitive_for_standalone(
filter_message: Callable[[str], str],
) -> None:
"""Test that standalone matching is case-insensitive."""
original = "Using NSEC1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f and CaShuA123abc"
expected = "Using [REDACTED] and [REDACTED]"
assert filter_message(original) == expected

View File

@@ -1,172 +0,0 @@
'use client';
import { useAuth } from '@/lib/auth/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import Link from 'next/link';
import { ArrowUpCircleIcon } from 'lucide-react';
import { registerUser, SchemaRegisterProps } from '@/lib/api/services/auth';
export default function NostrRegisterPage() {
const router = useRouter();
const { connectNostr } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState<SchemaRegisterProps>({
npub: '',
name: '',
});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleNostrConnect = async () => {
setIsLoading(true);
try {
const publicKey = await connectNostr();
if (publicKey) {
setFormData((prev) => ({ ...prev, npub: publicKey }));
toast.success(
'Nostr connected. Please enter your name to complete registration.'
);
} else {
toast.error(
'Failed to connect. Please make sure your Nostr extension is installed and enabled.'
);
}
} catch (error) {
console.error('Nostr connection error:', error);
toast.error('Failed to connect to Nostr. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.npub || formData.npub.length < 10) {
toast.error('Please enter a valid Nostr public key');
return;
}
if (!formData.name) {
toast.error('Please enter your name');
return;
}
setIsLoading(true);
try {
// Register the user
const result = await registerUser(formData);
console.log('Registration successful:', result);
toast.success('Account created successfully');
router.push('/login');
} catch (error) {
console.error('Registration error:', error);
toast.error('Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className='flex min-h-screen items-center justify-center p-4'>
<div className='w-full max-w-md'>
<div className='flex flex-col gap-6'>
<form onSubmit={handleRegister}>
<div className='flex flex-col gap-6'>
<div className='flex flex-col items-center gap-2'>
<Link
href='/'
className='flex flex-col items-center gap-2 font-medium'
>
<div className='flex h-8 w-8 items-center justify-center rounded-md'>
<ArrowUpCircleIcon className='size-6' />
</div>
<span className='sr-only'>Routstr</span>
</Link>
<h1 className='text-xl font-bold'>Create an Account</h1>
<div className='text-center text-sm'>
Already have an account?{' '}
<Link href='/login' className='underline underline-offset-4'>
Sign in
</Link>
</div>
</div>
<div className='flex flex-col gap-6'>
<div className='grid gap-2'>
<Label htmlFor='npub'>Nostr Public Key (npub)</Label>
<Input
id='npub'
name='npub'
type='text'
placeholder='npub1...'
value={formData.npub}
onChange={handleInputChange}
required
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='name'>Name</Label>
<Input
id='name'
name='name'
type='text'
placeholder='John Doe'
value={formData.name}
onChange={handleInputChange}
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create Account'}
</Button>
</div>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or
</span>
</div>
<div className='grid gap-4'>
<Button
variant='outline'
className='w-full'
onClick={handleNostrConnect}
disabled={isLoading}
type='button'
>
<svg
className='mr-2 h-4 w-4'
viewBox='0 0 256 256'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M158.4 28.4c-31.8-31.8-83.1-31.8-114.9 0s-31.8 83.1 0 114.9l57.4 57.4 57.4-57.4c31.8-31.8 31.8-83.1 0-114.9z'
fill='currentColor'
/>
<path
d='M215.8 199.3c-31.8-31.8-83.1-31.8-114.9 0L43.6 256l57.4-57.4c31.8-31.8 31.8-83.1 0-114.9L158.4 28.4 101 85.8c-31.8 31.8-31.8 83.1 0 114.9l114.8-1.4z'
fill='currentColor'
/>
</svg>
Connect with Nostr Extension
</Button>
</div>
</div>
</form>
<div className='text-muted-foreground hover:[&_a]:text-primary text-center text-xs text-balance [&_a]:underline [&_a]:underline-offset-4'>
By clicking create account, you agree to our{' '}
<Link href='#'>Terms of Service</Link> and{' '}
<Link href='#'>Privacy Policy</Link>.
</div>
</div>
</div>
</div>
);
}

60
ui/app/balances/page.tsx Normal file
View File

@@ -0,0 +1,60 @@
'use client';
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';
export default function BalancesPage() {
const { displayUnit } = useCurrencyStore();
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
refetchInterval: 120_000,
staleTime: 60_000,
});
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>
<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>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -6,8 +6,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: Geist, sans-serif;
--font-mono: Geist Mono, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -41,75 +41,152 @@
--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);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--radius: 0.5rem;
--background: oklch(0.99 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--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.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
--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;
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--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.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
--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);
}
@layer base {
@@ -118,6 +195,7 @@
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}

View File

@@ -78,8 +78,8 @@ export default function AdminLoginPage(): ReactElement {
};
return (
<div className='flex min-h-screen items-center justify-center bg-gray-50 p-4'>
<Card className='w-full max-w-md'>
<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

View File

@@ -0,0 +1,212 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} 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;
}
interface LogDetailsDialogProps {
log: LogEntry | null;
isOpen: boolean;
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,
onClose,
}: LogDetailsDialogProps) {
const [copiedField, setCopiedField] = useState<string | null>(null);
if (!log) return null;
const copyToClipboard = (text: string, fieldName?: string) => {
navigator.clipboard.writeText(text);
if (fieldName) {
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
}
};
const allFields = Object.keys(log).filter((key) => key !== 'key');
const standardFields = [
'asctime',
'name',
'levelname',
'message',
'pathname',
'lineno',
'version',
'request_id',
];
const extraFields = allFields.filter((key) => !standardFields.includes(key));
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Badge variant='outline' className={getLevelColor(log.levelname)}>
{log.levelname}
</Badge>
<span>Log Entry Details</span>
</DialogTitle>
<DialogDescription>
{log.asctime} {log.name} {log.pathname}:{log.lineno}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
<div className='space-y-6'>
<div>
<h4 className='mb-2 text-sm font-medium'>Message</h4>
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
<pre className='font-mono text-sm break-all whitespace-pre'>
{log.message}
</pre>
</div>
</div>
<div>
<h4 className='mb-3 text-sm font-medium'>Standard Fields</h4>
<div className='grid grid-cols-1 gap-3'>
{standardFields.map((field) => (
<div key={field} className='flex flex-col space-y-1'>
<div className='flex items-center justify-between gap-2'>
<span className='text-muted-foreground text-xs font-medium uppercase'>
{field}
</span>
{field === 'request_id' && (
<Button
variant='outline'
size='sm'
onClick={() =>
copyToClipboard(
String(log[field as keyof LogEntry] || ''),
field
)
}
className='h-6 flex-shrink-0 px-2'
>
{copiedField === field ? (
<>
<Check className='mr-1 h-3 w-3' />
Copied
</>
) : (
<>
<Copy className='mr-1 h-3 w-3' />
Copy
</>
)}
</Button>
)}
</div>
<div className='bg-muted max-h-32 overflow-auto rounded p-2'>
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
{String(log[field as keyof LogEntry] || 'N/A')}
</pre>
</div>
</div>
))}
</div>
</div>
{extraFields.length > 0 && (
<div>
<h4 className='mb-3 text-sm font-medium'>Additional Fields</h4>
<div className='grid grid-cols-1 gap-3'>
{extraFields.map((field) => (
<div key={field} className='flex flex-col space-y-1'>
<span className='text-muted-foreground truncate text-xs font-medium uppercase'>
{field}
</span>
<div className='bg-muted max-h-48 overflow-auto rounded p-2'>
{typeof log[field] === 'object' ? (
<pre className='font-mono text-xs break-all whitespace-pre-wrap'>
{JSON.stringify(log[field], null, 2)}
</pre>
) : (
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
{String(log[field] || 'N/A')}
</pre>
)}
</div>
</div>
))}
</div>
</div>
)}
<div>
<div className='mb-3 flex items-center justify-between'>
<h4 className='text-sm font-medium'>Raw JSON</h4>
<Button
variant='outline'
size='sm'
onClick={() =>
copyToClipboard(JSON.stringify(log, null, 2), 'json')
}
className='h-6 px-2'
>
{copiedField === 'json' ? (
<>
<Check className='mr-1 h-3 w-3' />
Copied
</>
) : (
<>
<Copy className='mr-1 h-3 w-3' />
Copy
</>
)}
</Button>
</div>
<div className='bg-muted max-h-64 overflow-auto rounded-md p-4'>
<pre className='text-xs break-all whitespace-pre-wrap'>
{JSON.stringify(log, null, 2)}
</pre>
</div>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,122 @@
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;
}
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) =>
![
'asctime',
'name',
'levelname',
'message',
'pathname',
'lineno',
'version',
'request_id',
].includes(key)
);
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'
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>
</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>
)}
</div>
</div>
)}
</div>
);
}

296
ui/app/logs/log-filters.tsx Normal file
View File

@@ -0,0 +1,296 @@
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { CalendarIcon, Filter, X } from 'lucide-react';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
interface LogFiltersProps {
selectedDate: string;
selectedLevel: string;
requestId: string;
searchText: string;
limit: number;
onDateChange: (date: string) => void;
onLevelChange: (level: string) => void;
onRequestIdChange: (requestId: string) => void;
onSearchTextChange: (searchText: string) => void;
onLimitChange: (limit: number) => void;
onClearFilters: () => void;
}
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
export function LogFilters({
selectedDate,
selectedLevel,
requestId,
searchText,
limit,
onDateChange,
onLevelChange,
onRequestIdChange,
onSearchTextChange,
onLimitChange,
onClearFilters,
}: LogFiltersProps) {
const isPreset = PRESET_LIMITS.includes(limit.toString());
const [customLimit, setCustomLimit] = useState<string>(
isPreset ? '' : limit.toString()
);
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
const [date, setDate] = useState<Date | undefined>(
selectedDate && selectedDate !== 'all'
? new Date(selectedDate + 'T00:00:00')
: undefined
);
useEffect(() => {
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
setIsCustom(!currentIsPreset);
if (!currentIsPreset) {
setCustomLimit(limit.toString());
}
}, [limit]);
useEffect(() => {
if (selectedDate === 'all' || !selectedDate) {
setDate(undefined);
} else {
const d = new Date(selectedDate + 'T00:00:00');
setDate(isNaN(d.getTime()) ? undefined : d);
}
}, [selectedDate]);
const handleLimitChange = (value: string) => {
if (value === 'custom') {
setIsCustom(true);
setCustomLimit(limit.toString());
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(Number(value));
}
};
const handleCustomLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setCustomLimit(value);
};
const handleCustomLimitApply = () => {
const numValue = parseInt(customLimit);
if (!isNaN(numValue) && numValue > 0) {
onLimitChange(numValue);
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(100);
}
};
const handleCustomLimitKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === 'Enter') {
handleCustomLimitApply();
}
};
const handleDateSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate);
if (selectedDate) {
onDateChange(format(selectedDate, 'yyyy-MM-dd'));
} else {
onDateChange('all');
}
};
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Filter className='h-5 w-5' />
Filters
</CardTitle>
<CardDescription>
Filter logs by date, level, request ID, text search, and limit
</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='date'>Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className={cn(
'w-full justify-start text-left font-normal',
!date && 'text-muted-foreground'
)}
>
<CalendarIcon className='mr-2 h-4 w-4' />
{date ? format(date, 'PPP') : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={date}
onSelect={handleDateSelect}
initialFocus
/>
</PopoverContent>
</Popover>
{date && (
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => handleDateSelect(undefined)}
className='w-full'
>
<X className='mr-2 h-4 w-4' />
Clear date
</Button>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='level'>Log Level</Label>
<Select value={selectedLevel} onValueChange={onLevelChange}>
<SelectTrigger>
<SelectValue placeholder='Select level' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All levels</SelectItem>
{LOG_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
<Input
id='request-id'
type='text'
placeholder='Search by request ID'
value={requestId}
onChange={(e) => onRequestIdChange(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='search-text' className='flex items-center gap-1'>
<span>Text Search</span>
<span className='text-muted-foreground text-xs font-normal'>
(can be slow)
</span>
</Label>
<Input
id='search-text'
type='text'
placeholder='Search in message and name'
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='limit'>Limit</Label>
{isCustom ? (
<div className='flex gap-2'>
<Input
id='limit'
type='number'
min='1'
placeholder='Enter custom limit'
value={customLimit}
onChange={handleCustomLimitChange}
onKeyDown={handleCustomLimitKeyDown}
onBlur={handleCustomLimitApply}
autoFocus
className='flex-1'
/>
<Button
type='button'
variant='secondary'
size='sm'
onClick={() => {
setIsCustom(false);
setCustomLimit('');
if (!isPreset) {
onLimitChange(100);
}
}}
>
Cancel
</Button>
</div>
) : (
<Select
value={isPreset ? limit.toString() : 'custom'}
onValueChange={handleLimitChange}
>
<SelectTrigger>
<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>
<SelectItem value='custom'>Custom...</SelectItem>
</SelectContent>
</Select>
)}
{!isCustom && !isPreset && (
<p className='text-muted-foreground text-xs'>Custom: {limit}</p>
)}
</div>
<div className='space-y-2'>
<Label>&nbsp;</Label>
<Button
onClick={onClearFilters}
variant='outline'
className='w-full'
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

178
ui/app/logs/page.tsx Normal file
View File

@@ -0,0 +1,178 @@
'use client';
import { useState } 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,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { FileText, RefreshCw } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { LogEntry, LogsResponse } from './types';
import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
import { LogDetailsDialog } from './log-details-dialog';
export default function LogsPage() {
const [selectedDate, setSelectedDate] = useState<string>('all');
const [selectedLevel, setSelectedLevel] = useState<string>('all');
const [requestId, setRequestId] = useState<string>('');
const [searchText, setSearchText] = useState<string>('');
const [limit, setLimit] = useState<number>(100);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const {
data: logsData,
refetch: refetchLogs,
isLoading,
} = useQuery({
queryKey: [
'logs',
selectedDate,
selectedLevel,
requestId,
searchText,
limit,
],
queryFn: () =>
apiClient.get<LogsResponse>('/admin/api/logs', {
date: selectedDate === 'all' ? undefined : selectedDate,
level: selectedLevel === 'all' ? undefined : selectedLevel,
request_id: requestId || undefined,
search: searchText || undefined,
limit: limit,
}),
refetchInterval: 30000,
});
const handleClearFilters = () => {
setSelectedDate('all');
setSelectedLevel('all');
setRequestId('');
setSearchText('');
setLimit(100);
};
const handleLogClick = (entry: LogEntry) => {
setSelectedLog(entry);
setIsDialogOpen(true);
};
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'
>
<RefreshCw className='mr-2 h-4 w-4' />
Refresh
</Button>
</div>
<LogFilters
selectedDate={selectedDate}
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
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) && (
<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}"`}
</CardDescription>
)}
</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>
) : 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>
<LogDetailsDialog
log={selectedLog}
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</div>
</SidebarInset>
</SidebarProvider>
);
}

25
ui/app/logs/types.ts Normal file
View File

@@ -0,0 +1,25 @@
export 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;
}
export interface LogsResponse {
logs: LogEntry[];
total: number;
date: string | null;
level: string | null;
request_id: string | null;
search: string | null;
limit: number;
}
export interface DatesResponse {
dates: string[];
}

View File

@@ -34,7 +34,6 @@ export default function ModelsPage() {
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo(() => {
if (!models) return {};
return groupAndSortModelsByProvider(models);
}, [models]);
@@ -43,7 +42,14 @@ export default function ModelsPage() {
}, [groups]);
const providerInfo = useMemo(() => {
return Object.entries(groupedModels).map(([provider, providerModels]) => {
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
@@ -59,7 +65,7 @@ export default function ModelsPage() {
hasGroupApiKey: !!groupData?.group_api_key,
};
});
}, [groupedModels, groupDataMap]);
}, [groupedModels, groupDataMap, groups]);
return (
<SidebarProvider>
@@ -158,9 +164,9 @@ export default function ModelsPage() {
</div>
</TabsContent>
{Object.entries(groupedModels).map(
([provider, providerModels]) => {
const groupData = groupDataMap.get(provider);
{providerInfo.map(
({ provider, totalModels, groupData }) => {
const providerModels = groupedModels[provider] || [];
return (
<TabsContent key={provider} value={provider}>
@@ -190,9 +196,61 @@ export default function ModelsPage() {
{groupData.group_url}
</span>
)}
{totalModels === 0 && (
<span className='text-muted-foreground'>
No models configured
</span>
)}
</div>
</div>
</div>
{totalModels === 0 && (
<Alert className='mb-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='space-y-2'>
<p className='font-medium'>
No models found for this provider
</p>
<div className='space-y-1 text-sm'>
<p className='font-medium'>
Common issues:
</p>
<ul className='ml-2 list-inside list-disc space-y-1'>
<li>
<strong>API credentials:</strong>{' '}
Check if the API key is correct
and has the right permissions
</li>
<li>
<strong>Base URL:</strong> Verify
the base URL is correct for your
provider
</li>
<li>
<strong>Network access:</strong>{' '}
Ensure the server can reach the
provider&apos;s API endpoint
</li>
<li>
<strong>Provider status:</strong>{' '}
The upstream provider might be
temporarily unavailable
</li>
</ul>
{groupData?.group_url && (
<p className='text-muted-foreground mt-2 text-xs'>
Current endpoint:{' '}
<code className='bg-muted rounded px-1'>
{groupData.group_url}
</code>
</p>
)}
</div>
</div>
</AlertDescription>
</Alert>
)}
<ModelSelector
filterProvider={provider}
groupData={groupData}

View File

@@ -5,81 +5,375 @@ 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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { DisplayUnit } from '@/lib/types/units';
import { UsageMetricsChart } from '@/components/usage-metrics-chart';
import { UsageSummaryCards } from '@/components/usage-summary-cards';
import { ErrorDetailsTable } from '@/components/error-details-table';
import { RevenueByModelTable } from '@/components/revenue-by-model-table';
import { DashboardBalanceSummary } from '@/components/dashboard-balance-summary';
import { AdminService } from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RefreshCw } from 'lucide-react';
import { useCurrencyStore } from '@/lib/stores/currency';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { CheatSheet } from '@/components/landing/cheat-sheet';
import { ConfigurationService } from '@/lib/api/services/configuration';
export default function Page() {
const [displayUnit, setDisplayUnit] = useState<DisplayUnit>('sat');
export default function DashboardPage() {
const [timeRange, setTimeRange] = useState('24');
const [interval, setInterval] = useState('15');
const { displayUnit } = useCurrencyStore();
const [isAuthenticated, setIsAuthenticated] = useState(() => {
if (typeof window === 'undefined') {
return false;
}
return ConfigurationService.isTokenValid();
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const syncAuthState = (): void => {
setIsAuthenticated(ConfigurationService.isTokenValid());
};
syncAuthState();
window.addEventListener('storage', syncAuthState);
return () => {
window.removeEventListener('storage', syncAuthState);
};
}, []);
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
enabled: isAuthenticated,
refetchInterval: 120_000,
staleTime: 60_000,
});
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
useEffect(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
setDisplayUnit('sat');
}
}, [displayUnit, usdPerSat]);
const {
data: metricsData,
isLoading: metricsLoading,
refetch: refetchMetrics,
} = useQuery({
queryKey: ['usage-metrics', interval, timeRange],
queryFn: () =>
AdminService.getUsageMetrics(parseInt(interval), parseInt(timeRange)),
enabled: isAuthenticated,
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: summaryData,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useQuery({
queryKey: ['usage-summary', timeRange],
queryFn: () => AdminService.getUsageSummary(parseInt(timeRange)),
enabled: isAuthenticated,
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: errorData,
isLoading: errorLoading,
refetch: refetchErrors,
} = useQuery({
queryKey: ['usage-errors', timeRange],
queryFn: () => AdminService.getErrorDetails(parseInt(timeRange), 100),
enabled: isAuthenticated,
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: revenueByModelData,
isLoading: revenueByModelLoading,
refetch: refetchRevenueByModel,
} = useQuery({
queryKey: ['revenue-by-model', timeRange],
queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange), 20),
enabled: isAuthenticated,
refetchInterval: 60_000,
staleTime: 30_000,
});
if (!isAuthenticated) {
return <CheatSheet />;
}
const handleRefresh = () => {
refetchMetrics();
refetchSummary();
refetchErrors();
refetchRevenueByModel();
};
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 className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8'>
<h1 className='mb-6 text-3xl font-bold tracking-tight'>
Dashboard
</h1>
<DashboardBalanceSummary
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>
Admin Dashboard
</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
<h2 className='text-xl font-semibold tracking-tight'>
Usage Analytics
</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Monitor requests, errors, and revenue over the last {timeRange}{' '}
hours
</p>
</div>
<div className='flex items-center'>
<ToggleGroup
type='single'
value={displayUnit}
onValueChange={(value) => {
if (value) {
setDisplayUnit(value as DisplayUnit);
}
}}
variant='outline'
size='sm'
>
<ToggleGroupItem value='msat'>mSAT</ToggleGroupItem>
<ToggleGroupItem value='sat'>sat</ToggleGroupItem>
<ToggleGroupItem value='usd' disabled={!usdPerSat}>
USD
</ToggleGroupItem>
</ToggleGroup>
<div className='flex items-center gap-4'>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select time range' />
</SelectTrigger>
<SelectContent>
<SelectItem value='1'>Last Hour</SelectItem>
<SelectItem value='6'>Last 6 Hours</SelectItem>
<SelectItem value='24'>Last 24 Hours</SelectItem>
<SelectItem value='72'>Last 3 Days</SelectItem>
<SelectItem value='168'>Last Week</SelectItem>
</SelectContent>
</Select>
<Select value={interval} onValueChange={setInterval}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select interval' />
</SelectTrigger>
<SelectContent>
<SelectItem value='5'>5 Minutes</SelectItem>
<SelectItem value='15'>15 Minutes</SelectItem>
<SelectItem value='30'>30 Minutes</SelectItem>
<SelectItem value='60'>1 Hour</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleRefresh} variant='outline' size='icon'>
<RefreshCw className='h-4 w-4' />
</Button>
</div>
</div>
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
<div className='space-y-6'>
{summaryLoading ? (
<div className='py-8 text-center'>Loading summary...</div>
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<div className='grid gap-6 lg:grid-cols-2'>
{metricsLoading ? (
<div className='col-span-2 py-8 text-center'>
Loading metrics...
</div>
) : metricsData && metricsData.metrics.length > 0 ? (
<>
<div className='col-span-full'>
<UsageMetricsChart
data={
metricsData.metrics.map((m) => ({
...m,
revenue_sats: m.revenue_msats / 1000,
refunds_sats: m.refunds_msats / 1000,
net_revenue_sats:
(m.revenue_msats - m.refunds_msats) / 1000,
})) as Array<
Record<string, unknown> & { timestamp: string }
>
}
title='Revenue Over Time (sats)'
dataKeys={[
{
key: 'revenue_sats',
name: 'Revenue',
color: '#10b981',
},
{
key: 'net_revenue_sats',
name: 'Net Revenue',
color: '#059669',
},
{
key: 'refunds_sats',
name: 'Refunds',
color: '#ef4444',
},
]}
/>
</div>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
title='Request Volume'
dataKeys={[
{
key: 'total_requests',
name: 'Total Requests',
color: '#3b82f6',
},
{
key: 'successful_chat_completions',
name: 'Successful',
color: '#22c55e',
},
{
key: 'failed_requests',
name: 'Failed',
color: '#f43f5e',
},
]}
/>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
title='Error Tracking'
dataKeys={[
{
key: 'errors',
name: 'Errors',
color: '#f97316',
},
{
key: 'warnings',
name: 'Warnings',
color: '#eab308',
},
{
key: 'upstream_errors',
name: 'Upstream Errors',
color: '#dc2626',
},
]}
/>
<UsageMetricsChart
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
title='Payment Activity'
dataKeys={[
{
key: 'payment_processed',
name: 'Payments Processed',
color: '#8b5cf6',
},
]}
/>
<div className='space-y-6'>
{summaryData && summaryData.unique_models.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Active Models</CardTitle>
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{summaryData.unique_models.map((model) => (
<span
key={model}
className='bg-secondary text-secondary-foreground inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold'
>
{model}
</span>
))}
</div>
</CardContent>
</Card>
)}
{summaryData &&
summaryData.error_types &&
Object.keys(summaryData.error_types).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Error Types Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className='space-y-2'>
{Object.entries(summaryData.error_types)
.sort(([, a], [, b]) => b - a)
.map(([type, count]) => (
<div
key={type}
className='flex items-center justify-between'
>
<span className='text-sm font-medium'>
{type}
</span>
<span className='text-muted-foreground text-sm'>
{count}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</>
) : (
<Card className='col-span-2'>
<CardHeader>
<CardTitle>No Data Available</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground'>
No metrics data found for the selected time range. This
could be because no requests have been logged yet or the
log files are not available.
</p>
</CardContent>
</Card>
)}
</div>
<div className='col-span-full'>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
{revenueByModelLoading ? (
<div className='py-8 text-center'>
Loading revenue by model...
</div>
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
<RevenueByModelTable
models={revenueByModelData.models}
totalRevenue={revenueByModelData.total_revenue_sats}
/>
</div>
) : null}
{errorLoading ? (
<div className='py-8 text-center'>Loading errors...</div>
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}
</div>
</div>
</SidebarInset>

View File

@@ -18,7 +18,9 @@ import {
UpstreamProvider,
CreateUpstreamProvider,
UpdateUpstreamProvider,
AdminModel,
} from '@/lib/api/services/admin';
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
@@ -54,7 +56,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
function ProviderBalance({ providerId }: { providerId: number }) {
function ProviderBalance({
providerId,
platformUrl,
}: {
providerId: number;
platformUrl?: string | null;
}) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
const [topupError, setTopupError] = useState('');
@@ -68,7 +76,11 @@ function ProviderBalance({ providerId }: { providerId: number }) {
);
const queryClient = useQueryClient();
const { data: balanceData, isLoading, error } = useQuery({
const {
data: balanceData,
isLoading,
error,
} = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
@@ -100,7 +112,10 @@ function ProviderBalance({ providerId }: { providerId: number }) {
mutationFn: async (amount: number) => {
console.log('Calling top-up API with:', { providerId, amount });
try {
const result = await AdminService.initiateProviderTopup(providerId, amount);
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
console.log('API returned:', result);
return result;
} catch (err) {
@@ -112,11 +127,8 @@ function ProviderBalance({ providerId }: { providerId: number }) {
console.log('Top-up response:', data);
console.log('Type of data:', typeof data);
console.log('Keys in data:', Object.keys(data || {}));
if (
data?.topup_data?.payment_request &&
data?.topup_data?.invoice_id
) {
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
setInvoiceData({
payment_request: data.topup_data.payment_request as string,
invoice_id: data.topup_data.invoice_id as string,
@@ -136,6 +148,10 @@ function ProviderBalance({ providerId }: { providerId: number }) {
});
const handleTopup = () => {
// If no dialog open logic (which depends on API implementation),
// we check if we should redirect or open dialog based on available info
// But since this function is called inside the dialog, we might want to change
// how the "Top Up" button behaves instead.
const amount = parseFloat(topupAmount);
if (isNaN(amount)) {
@@ -151,6 +167,35 @@ function ProviderBalance({ providerId }: { providerId: number }) {
topupMutation.mutate(amount);
};
const handleTopUpClick = () => {
// Check if the provider supports direct topup (currently only PPQ.AI effectively)
// We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability
// For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented),
// we should redirect.
// However, the prompt asks to redirect if topup is not implemented.
// The backend throws 500/400 if not implemented.
// A better approach is to check if we have a platform URL and maybe redirect there
// if we know it's not supported.
// BUT, we don't know for sure if it's supported without checking metadata or trying.
// Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance".
// Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect?
// Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect?
// The user specifically mentioned "like in openrouter".
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
const handleCloseDialog = () => {
setIsTopupDialogOpen(false);
setTopupAmount('');
@@ -170,12 +215,18 @@ function ProviderBalance({ providerId }: { providerId: number }) {
const balance = balanceData.balance_data;
let displayValue = 'N/A';
if (typeof balance.balance === 'number') {
displayValue = `$${balance.balance.toFixed(2)}`;
} else if (typeof balance.balance === 'string') {
displayValue = balance.balance;
} else if (balance.amount !== undefined) {
displayValue = `$${Number(balance.amount).toFixed(2)}`;
if (typeof balance === 'number') {
displayValue = `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
// Legacy support for object response
const b = balance as Record<string, unknown>;
if (typeof b.balance === 'number') {
displayValue = `$${b.balance.toFixed(2)}`;
} else if (typeof b.balance === 'string') {
displayValue = b.balance;
} else if (b.amount !== undefined) {
displayValue = `$${Number(b.amount).toFixed(2)}`;
}
}
return (
@@ -183,7 +234,7 @@ function ProviderBalance({ providerId }: { providerId: number }) {
<Button
variant='outline'
size='sm'
onClick={() => setIsTopupDialogOpen(true)}
onClick={handleTopUpClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='w-full font-mono sm:w-auto'
@@ -223,9 +274,7 @@ function ProviderBalance({ providerId }: { providerId: number }) {
<path d='M5 13l4 4L19 7'></path>
</svg>
</div>
<p className='text-center font-semibold'>
Top-up successful!
</p>
<p className='text-center font-semibold'>Top-up successful!</p>
</div>
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'>
@@ -338,6 +387,17 @@ export default function ProvidersPage() {
);
const [viewingModels, setViewingModels] = useState<number | null>(null);
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
const [modelDialogState, setModelDialogState] = useState<{
isOpen: boolean;
providerId: number | null;
mode: 'create' | 'edit' | 'override';
initialData?: AdminModel | null;
}>({
isOpen: false,
providerId: null,
mode: 'create',
initialData: null,
});
const [formData, setFormData] = useState<CreateUpstreamProvider>({
provider_type: 'openrouter',
@@ -345,8 +405,13 @@ export default function ProvidersPage() {
api_key: '',
api_version: null,
enabled: true,
provider_fee: 1.06,
});
const getProviderFeePlaceholder = (type: string) => {
return type === 'openrouter' ? 'Default: 1.06 (6%)' : 'Default: 1.01 (1%)';
};
const { data: providerTypes = [] } = useQuery({
queryKey: ['provider-types'],
queryFn: () => AdminService.getProviderTypes(),
@@ -423,7 +488,9 @@ export default function ProvidersPage() {
...formData,
api_key: String(response.account_data.api_key),
});
toast.success('Account created successfully! API key has been filled in.');
toast.success(
'Account created successfully! API key has been filled in.'
);
} else {
toast.success('Account created, but no API key returned.');
}
@@ -447,7 +514,11 @@ export default function ProvidersPage() {
};
const handleCreate = () => {
createMutation.mutate(formData);
const data = { ...formData };
if (data.provider_fee === undefined || data.provider_fee === null) {
data.provider_fee = data.provider_type === 'openrouter' ? 1.06 : 1.01;
}
createMutation.mutate(data);
};
const handleEdit = (provider: UpstreamProvider) => {
@@ -458,6 +529,7 @@ export default function ProvidersPage() {
api_key: '',
api_version: provider.api_version || null,
enabled: provider.enabled,
provider_fee: provider.provider_fee,
});
setIsEditDialogOpen(true);
};
@@ -469,6 +541,7 @@ export default function ProvidersPage() {
base_url: formData.base_url,
api_version: formData.api_version,
enabled: formData.enabled,
provider_fee: formData.provider_fee,
};
if (formData.api_key) {
updateData.api_key = formData.api_key;
@@ -522,6 +595,33 @@ export default function ProvidersPage() {
}
};
const handleAddModel = (providerId: number) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'create',
initialData: null,
});
};
const handleEditModel = (providerId: number, model: AdminModel) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'edit',
initialData: model,
});
};
const handleOverrideModel = (providerId: number, model: AdminModel) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'override',
initialData: model,
});
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
@@ -561,11 +661,12 @@ export default function ProvidersPage() {
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData({
...formData,
setFormData((prev) => ({
...prev,
provider_type: value,
base_url: getDefaultBaseUrl(value),
});
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
}));
}}
>
<SelectTrigger>
@@ -662,6 +763,32 @@ export default function ProvidersPage() {
/>
<Label htmlFor='enabled'>Enabled</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor='provider_fee'>
Provider Fee (Multiplier)
</Label>
<Input
id='provider_fee'
type='number'
step='0.001'
min='1.0'
value={formData.provider_fee || ''}
onChange={(e) =>
setFormData({
...formData,
provider_fee: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder={getProviderFeePlaceholder(
formData.provider_type
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
<DialogFooter>
<Button
@@ -736,7 +863,12 @@ export default function ProvidersPage() {
<div className='flex flex-wrap items-center gap-2'>
{canShowBalance(provider.provider_type) &&
provider.api_key && (
<ProviderBalance providerId={provider.id} />
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
)}
/>
)}
<Button
variant='outline'
@@ -800,9 +932,21 @@ export default function ProvidersPage() {
// No provided models - show custom models directly without tabs
<div className='space-y-2'>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No models configured. Add custom models to
use this provider.
<div className='flex flex-col items-center justify-center gap-2 py-4'>
<div className='text-muted-foreground text-sm'>
No models configured. Add custom models
to use this provider.
</div>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
</div>
) : (
<div className='space-y-2'>
@@ -833,9 +977,24 @@ export default function ProvidersPage() {
{model.description || model.name}
</div>
</div>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
</div>
</div>
))}
@@ -886,12 +1045,24 @@ export default function ProvidersPage() {
value='custom'
className='mt-4 space-y-2'
>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground mb-3 text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<div className='flex items-center justify-between'>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
@@ -927,9 +1098,24 @@ export default function ProvidersPage() {
model.name}
</div>
</div>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
</div>
</div>
)
@@ -964,9 +1150,25 @@ export default function ProvidersPage() {
model.name}
</div>
</div>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
handleOverrideModel(
provider.id,
model
)
}
>
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
</div>
</div>
)
@@ -1001,11 +1203,12 @@ export default function ProvidersPage() {
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData({
...formData,
setFormData((prev) => ({
...prev,
provider_type: value,
base_url: getDefaultBaseUrl(value),
});
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
}));
}}
>
<SelectTrigger>
@@ -1089,6 +1292,32 @@ export default function ProvidersPage() {
/>
<Label htmlFor='edit_enabled'>Enabled</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit_provider_fee'>
Provider Fee (Multiplier)
</Label>
<Input
id='edit_provider_fee'
type='number'
step='0.001'
min='1.0'
value={formData.provider_fee || ''}
onChange={(e) =>
setFormData({
...formData,
provider_fee: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder={getProviderFeePlaceholder(
formData.provider_type
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
<DialogFooter>
<Button
@@ -1106,6 +1335,23 @@ export default function ProvidersPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-models', modelDialogState.providerId],
});
}}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
/>
)}
</SidebarInset>
</SidebarProvider>
);

View File

@@ -0,0 +1,956 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Loader2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
const listFromString = (value: string): string[] =>
value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
const listToString = (value: string[] | undefined | null): string =>
value && value.length > 0 ? value.join(', ') : '';
const FormSchema = z.object({
id: z.string().min(1, 'Model ID is required'),
name: z.string().min(1, 'Name is required'),
description: z.string().default(''),
context_length: z.coerce.number().min(0).default(8192),
modality: z.string().min(1, 'Modality is required'),
input_modalities_raw: z.string().default(''),
output_modalities_raw: z.string().default(''),
tokenizer: z.string().default(''),
instruct_type: z.string().default(''),
canonical_slug: z.string().default(''),
alias_ids_raw: z.string().default(''),
upstream_provider_id: z.string().default(''),
input_cost: z.coerce.number().min(0).default(0),
output_cost: z.coerce.number().min(0).default(0),
request_cost: z.coerce.number().min(0).default(0),
image_cost: z.coerce.number().min(0).default(0),
web_search_cost: z.coerce.number().min(0).default(0),
internal_reasoning_cost: z.coerce.number().min(0).default(0),
max_prompt_cost: z.coerce.number().min(0).default(0),
max_completion_cost: z.coerce.number().min(0).default(0),
max_cost: z.coerce.number().min(0).default(0),
per_request_limits_raw: z.string().default(''),
top_provider_context_length: z.coerce.number().min(0).optional(),
top_provider_max_completion_tokens: z.coerce.number().min(0).optional(),
top_provider_is_moderated: z.boolean().default(false),
enabled: z.boolean().default(true),
});
type FormData = z.output<typeof FormSchema>;
export interface AddProviderModelDialogProps {
providerId: number;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: AdminModel | null;
mode?: 'create' | 'edit' | 'override';
}
export function AddProviderModelDialog({
providerId,
isOpen,
onClose,
onSuccess,
initialData,
mode = 'create',
}: AddProviderModelDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPresetOpen, setIsPresetOpen] = useState(false);
const [selectedPresetLabel, setSelectedPresetLabel] =
useState('Select a preset');
const form = useForm<FormData>({
resolver: zodResolver(FormSchema) as never,
defaultValues: {
id: '',
name: '',
description: '',
context_length: 8192,
modality: 'text',
input_modalities_raw: 'text',
output_modalities_raw: 'text',
tokenizer: '',
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
internal_reasoning_cost: 0,
max_prompt_cost: 0,
max_completion_cost: 0,
max_cost: 0,
per_request_limits_raw: '',
top_provider_context_length: undefined,
top_provider_max_completion_tokens: undefined,
top_provider_is_moderated: false,
enabled: true,
},
});
const isOverride = useMemo(() => mode === 'override', [mode]);
const isEdit = useMemo(() => mode === 'edit', [mode]);
const { data: presets = [], isLoading: isLoadingPresets } = useQuery({
queryKey: ['openrouter-presets'],
queryFn: () => AdminService.getOpenRouterPresets(),
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (initialData) {
const architecture = initialData.architecture as Record<string, unknown>;
const pricing = initialData.pricing as Record<string, number>;
const topProvider = initialData.top_provider as Record<
string,
unknown
> | null;
form.reset({
id: initialData.id,
name: initialData.name,
description: initialData.description,
context_length: initialData.context_length,
modality:
typeof architecture?.modality === 'string'
? architecture.modality
: 'text',
input_modalities_raw: listToString(
(architecture?.input_modalities as string[]) || []
),
output_modalities_raw: listToString(
(architecture?.output_modalities as string[]) || []
),
tokenizer:
typeof architecture?.tokenizer === 'string'
? architecture.tokenizer
: '',
instruct_type:
typeof architecture?.instruct_type === 'string'
? architecture.instruct_type
: '',
canonical_slug: initialData.canonical_slug || '',
alias_ids_raw: listToString(initialData.alias_ids),
upstream_provider_id:
typeof initialData.upstream_provider_id === 'string'
? initialData.upstream_provider_id
: initialData.upstream_provider_id?.toString() || '',
input_cost: pricing?.prompt ?? 0,
output_cost: pricing?.completion ?? 0,
request_cost: pricing?.request ?? 0,
image_cost: pricing?.image ?? 0,
web_search_cost: pricing?.web_search ?? 0,
internal_reasoning_cost: pricing?.internal_reasoning ?? 0,
max_prompt_cost: pricing?.max_prompt_cost ?? 0,
max_completion_cost: pricing?.max_completion_cost ?? 0,
max_cost: pricing?.max_cost ?? 0,
per_request_limits_raw: initialData.per_request_limits
? JSON.stringify(initialData.per_request_limits, null, 2)
: '',
top_provider_context_length:
typeof topProvider?.context_length === 'number'
? topProvider.context_length
: undefined,
top_provider_max_completion_tokens:
typeof topProvider?.max_completion_tokens === 'number'
? topProvider.max_completion_tokens
: undefined,
top_provider_is_moderated:
typeof topProvider?.is_moderated === 'boolean'
? topProvider.is_moderated
: false,
enabled: initialData.enabled,
});
} else {
form.reset({
id: '',
name: '',
description: '',
context_length: 8192,
modality: 'text',
input_modalities_raw: 'text',
output_modalities_raw: 'text',
tokenizer: '',
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
internal_reasoning_cost: 0,
max_prompt_cost: 0,
max_completion_cost: 0,
max_cost: 0,
per_request_limits_raw: '',
top_provider_context_length: undefined,
top_provider_max_completion_tokens: undefined,
top_provider_is_moderated: false,
enabled: true,
});
}
}, [initialData, form, isOpen]);
const applyModelToForm = (model: AdminModel) => {
setSelectedPresetLabel(`${model.id}${model.name}`);
const architecture = model.architecture as Record<string, unknown>;
const pricing = model.pricing as Record<string, number>;
const topProvider = model.top_provider as Record<string, unknown> | null;
form.setValue('id', model.id);
form.setValue('name', model.name);
form.setValue('description', model.description || '');
form.setValue('context_length', model.context_length);
form.setValue(
'modality',
typeof architecture?.modality === 'string'
? architecture.modality
: 'text'
);
form.setValue(
'input_modalities_raw',
listToString((architecture?.input_modalities as string[]) || [])
);
form.setValue(
'output_modalities_raw',
listToString((architecture?.output_modalities as string[]) || [])
);
form.setValue(
'tokenizer',
typeof architecture?.tokenizer === 'string' ? architecture.tokenizer : ''
);
form.setValue(
'instruct_type',
typeof architecture?.instruct_type === 'string'
? architecture.instruct_type
: ''
);
form.setValue('canonical_slug', model.canonical_slug || '');
form.setValue('alias_ids_raw', listToString(model.alias_ids));
form.setValue(
'upstream_provider_id',
typeof model.upstream_provider_id === 'string'
? model.upstream_provider_id
: model.upstream_provider_id?.toString() || ''
);
form.setValue('input_cost', pricing?.prompt ?? 0);
form.setValue('output_cost', pricing?.completion ?? 0);
form.setValue('request_cost', pricing?.request ?? 0);
form.setValue('image_cost', pricing?.image ?? 0);
form.setValue('web_search_cost', pricing?.web_search ?? 0);
form.setValue('internal_reasoning_cost', pricing?.internal_reasoning ?? 0);
form.setValue('max_prompt_cost', pricing?.max_prompt_cost ?? 0);
form.setValue('max_completion_cost', pricing?.max_completion_cost ?? 0);
form.setValue('max_cost', pricing?.max_cost ?? 0);
form.setValue(
'per_request_limits_raw',
model.per_request_limits
? JSON.stringify(model.per_request_limits, null, 2)
: ''
);
form.setValue(
'top_provider_context_length',
typeof topProvider?.context_length === 'number'
? topProvider.context_length
: undefined
);
form.setValue(
'top_provider_max_completion_tokens',
typeof topProvider?.max_completion_tokens === 'number'
? topProvider.max_completion_tokens
: undefined
);
form.setValue(
'top_provider_is_moderated',
typeof topProvider?.is_moderated === 'boolean'
? topProvider.is_moderated
: false
);
form.setValue('enabled', model.enabled);
};
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
let perRequestLimits: Record<string, unknown> | null = null;
if (
data.per_request_limits_raw &&
data.per_request_limits_raw.trim().length
) {
try {
perRequestLimits = JSON.parse(data.per_request_limits_raw);
} catch {
toast.error('Per-request limits must be valid JSON');
setIsSubmitting(false);
return;
}
}
const adminModel: AdminModel = {
id: data.id,
name: data.name,
description: data.description || '',
created: Math.floor(Date.now() / 1000),
context_length: data.context_length,
architecture: {
modality: data.modality,
input_modalities: listFromString(
data.input_modalities_raw || data.modality
),
output_modalities: listFromString(
data.output_modalities_raw || data.modality
),
tokenizer: data.tokenizer || '',
instruct_type: data.instruct_type?.trim() || null,
},
pricing: {
prompt: data.input_cost,
completion: data.output_cost,
request: data.request_cost,
image: data.image_cost,
web_search: data.web_search_cost,
internal_reasoning: data.internal_reasoning_cost,
max_prompt_cost: data.max_prompt_cost,
max_completion_cost: data.max_completion_cost,
max_cost: data.max_cost,
},
per_request_limits: perRequestLimits,
top_provider:
data.top_provider_context_length ||
data.top_provider_max_completion_tokens ||
data.top_provider_is_moderated
? {
context_length: data.top_provider_context_length ?? null,
max_completion_tokens:
data.top_provider_max_completion_tokens ?? null,
is_moderated: data.top_provider_is_moderated,
}
: null,
upstream_provider_id: data.upstream_provider_id?.trim().length
? data.upstream_provider_id.trim()
: providerId,
canonical_slug: data.canonical_slug?.trim() || null,
alias_ids: listFromString(data.alias_ids_raw || ''),
enabled: data.enabled,
};
if (isEdit) {
await AdminService.updateProviderModel(providerId, data.id, adminModel);
toast.success('Model updated successfully');
} else {
await AdminService.createProviderModel(providerId, adminModel);
toast.success(
isOverride ? 'Model override created' : 'Model created successfully'
);
}
onSuccess();
onClose();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Unknown error saving model';
toast.error(`Failed to save model: ${message}`);
} finally {
setIsSubmitting(false);
}
};
const title = isEdit
? 'Edit Model'
: isOverride
? 'Override Model'
: 'Add Custom Model';
const description = isOverride
? 'Create a custom override for this upstream model'
: 'Add a new model configuration for this provider';
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[720px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-4 w-4' />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Presets</div>
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
<Popover open={isPresetOpen} onOpenChange={setIsPresetOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={isPresetOpen}
className='w-full justify-between overflow-hidden text-left text-sm'
>
<span className='truncate'>
{isLoadingPresets
? 'Loading presets...'
: selectedPresetLabel}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className='w-80 max-w-sm p-0'
align='start'
sideOffset={4}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={true}>
<CommandInput placeholder='Search presets...' />
<CommandList
className='max-h-64 overflow-y-auto overscroll-contain'
onWheel={(e) => e.stopPropagation()}
>
{isLoadingPresets ? (
<CommandEmpty>Loading presets...</CommandEmpty>
) : presets.length === 0 ? (
<CommandEmpty>No presets available.</CommandEmpty>
) : (
<CommandGroup heading='Presets'>
{presets.map((preset) => (
<CommandItem
key={preset.id}
value={preset.id}
onSelect={() => {
applyModelToForm(preset);
setIsPresetOpen(false);
}}
>
<div className='flex flex-col text-sm'>
<span className='font-medium'>{preset.id}</span>
<span className='text-muted-foreground text-xs'>
{preset.name}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
</div>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='id'
render={({ field }) => (
<FormItem>
<FormLabel>Model ID *</FormLabel>
<FormControl>
<Input
placeholder='e.g., gpt-5.1'
{...field}
disabled={isOverride || isEdit}
/>
</FormControl>
<FormDescription>
Unique identifier for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Display Name *</FormLabel>
<FormControl>
<Input placeholder='e.g., GPT-5.1' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Brief description...'
{...field}
rows={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='modality'
render={({ field }) => (
<FormItem>
<FormLabel>Modality *</FormLabel>
<FormControl>
<Input
placeholder='text, text+image->text, image->text, etc.'
{...field}
/>
</FormControl>
<FormDescription>
Composite modality label (e.g., text+image-&gt;text)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input type='number' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_modalities_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Input Modalities</FormLabel>
<FormControl>
<Input placeholder='text, image, file' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_modalities_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Output Modalities</FormLabel>
<FormControl>
<Input placeholder='text, image' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='tokenizer'
render={({ field }) => (
<FormItem>
<FormLabel>Tokenizer</FormLabel>
<FormControl>
<Input placeholder='e.g., GPT, tiktoken' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='instruct_type'
render={({ field }) => (
<FormItem>
<FormLabel>Instruct Type</FormLabel>
<FormControl>
<Input placeholder='e.g., chat, completion' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='canonical_slug'
render={({ field }) => (
<FormItem>
<FormLabel>Canonical Slug</FormLabel>
<FormControl>
<Input
placeholder='google/gemini-3-pro-preview-20251117'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='alias_ids_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Alias IDs</FormLabel>
<FormControl>
<Input placeholder='alias-1, alias-2' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='upstream_provider_id'
render={({ field }) => (
<FormItem>
<FormLabel>Upstream Provider ID</FormLabel>
<FormControl>
<Input placeholder='gemini' {...field} />
</FormControl>
<FormDescription>
Defaults to current provider if left blank
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='per_request_limits_raw'
render={({ field }) => (
<FormItem>
<FormLabel>Per-request Limits (JSON)</FormLabel>
<FormControl>
<Textarea
placeholder='{"requests_per_min": 60}'
{...field}
rows={4}
/>
</FormControl>
<FormDescription>
JSON object; leave empty for none
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>
Pricing (USD per 1M tokens)
</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Input Cost</FormLabel>
<FormControl>
<Input type='number' step='0.01' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Output Cost</FormLabel>
<FormControl>
<Input type='number' step='0.01' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='request_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Per Request Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='image_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Image Cost (per image)</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='web_search_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Web Search Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='internal_reasoning_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Internal Reasoning Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_prompt_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Prompt Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_completion_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Total Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>Top Provider (optional)</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3'>
<FormField
control={form.control}
name='top_provider_context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input
type='number'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='top_provider_max_completion_tokens'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Tokens</FormLabel>
<FormControl>
<Input
type='number'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='top_provider_is_moderated'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
Is Moderated
</FormLabel>
<FormDescription>
Whether provider enforces moderation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Enabled</FormLabel>
<FormDescription>Enable this model for use</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{isEdit
? 'Save Changes'
: isOverride
? 'Create Override'
: 'Create Model'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,15 +2,14 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
import {
type Model,
type ManualModel,
type GroupSettings,
} from '@/lib/api/schemas/models';
import { AdminService, type AdminModelGroup } from '@/lib/api/services/admin';
AdminService,
type AdminModelGroup,
type AdminModel,
} from '@/lib/api/services/admin';
type ModelGroup = AdminModelGroup;
import { AddModelForm } from '@/components/AddModelForm';
import { EditModelForm } from '@/components/EditModelForm';
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { EditGroupForm } from '@/components/EditGroupForm';
import { CollectModelsDialog } from '@/components/CollectModelsDialog';
import { formatCost } from '@/lib/services/costValidation';
@@ -80,14 +79,23 @@ export function ModelSelector({
}: ModelSelectorProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [, setHoveredModelId] = useState<string | null>(null);
const [isAddFormOpen, setIsAddFormOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | null>(null);
const [editingGroup, setEditingGroup] = useState<{
provider: string;
models: Model[];
groupData: ModelGroup;
} | null>(null);
const [isCollectDialogOpen, setIsCollectDialogOpen] = useState(false);
const [modelDialogState, setModelDialogState] = useState<{
isOpen: boolean;
providerId: number | null;
mode: 'create' | 'edit' | 'override';
initialData?: AdminModel | null;
}>({
isOpen: false,
providerId: null,
mode: 'create',
initialData: null,
});
// Bulk selection state
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
@@ -492,44 +500,104 @@ export function ModelSelector({
return hasIndividualApiKey || hasIndividualUrl;
};
// Handle manual model addition
const handleAddModel = async (newModel: ManualModel) => {
try {
// Find or create the provider group
let providerGroup = groups.find((g) => g.provider === newModel.provider);
const handleAddModelClick = (providerId: number) => {
setModelDialogState({
isOpen: true,
providerId,
mode: 'create',
initialData: null,
});
};
if (!providerGroup) {
providerGroup = await AdminService.createModelGroup({
provider: newModel.provider,
group_api_key: undefined,
});
}
const modelData = {
name: newModel.name,
full_name: newModel.name,
input_cost: newModel.input_cost,
output_cost: newModel.output_cost,
provider: newModel.provider,
modelType: newModel.modelType,
description: newModel.description || undefined,
contextLength: newModel.contextLength,
};
await AdminService.createModel(modelData);
await refetchModels();
toast.success(`Model "${newModel.name}" added successfully!`);
} catch (error) {
console.error('Error adding model:', error);
toast.error('Failed to add model. Please try again.');
throw error; // Re-throw to let the form handle the error
const handleEditModelClick = (model: Model) => {
if (!model.provider_id) {
toast.error('Provider ID missing for model');
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
name: model.name,
description: model.description || '',
created: new Date(model.createdAt).getTime() / 1000,
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
};
setModelDialogState({
isOpen: true,
providerId,
mode: 'edit',
initialData: adminModel,
});
};
const handleOverrideModelClick = (model: Model) => {
if (!model.provider_id) {
toast.error('Provider ID missing for model');
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
name: model.name,
description: model.description || '',
created: new Date(model.createdAt).getTime() / 1000,
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
};
setModelDialogState({
isOpen: true,
providerId,
mode: 'override',
initialData: adminModel,
});
};
// Handle model update
const handleModelUpdate = async () => {
await refetchModels();
setEditingModel(null);
};
// Handle group update
@@ -832,11 +900,18 @@ export function ModelSelector({
Delete All Overrides Permanently
</Button>
)}
{/* Model Management Actions
<Button onClick={() => setIsAddFormOpen(true)} className='gap-2'>
<Plus className='h-4 w-4' />
Add Model
</Button>
{/* Model Management Actions */}
{groupData && (
<Button
onClick={() => handleAddModelClick(parseInt(groupData.id))}
className='gap-2'
variant='outline'
>
<CheckSquare className='h-4 w-4' />
Add Custom Model
</Button>
)}
{/*
<Button
onClick={() => setIsCollectDialogOpen(true)}
variant='outline'
@@ -1080,15 +1155,28 @@ export function ModelSelector({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setEditingModel(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
{model.api_key_type !== 'remote' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEditModelClick(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
)}
{model.api_key_type === 'remote' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleOverrideModelClick(model);
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Override
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{model.soft_deleted ? (
<DropdownMenuItem
@@ -1215,23 +1303,16 @@ export function ModelSelector({
})}
{/* Forms and Dialogs */}
<AddModelForm
isOpen={isAddFormOpen}
onModelAdd={handleAddModel}
onCancel={() => setIsAddFormOpen(false)}
/>
{editingModel && (
<EditModelForm
model={editingModel}
providerId={
editingModel.provider_id
? parseInt(editingModel.provider_id)
: undefined
{modelDialogState.providerId && (
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onModelUpdate={handleModelUpdate}
onCancel={() => setEditingModel(null)}
isOpen={!!editingModel}
onSuccess={handleModelUpdate}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}
/>
)}

View File

@@ -2,10 +2,12 @@
import * as React from 'react';
import {
FileTextIcon,
DatabaseIcon,
LayoutDashboardIcon,
ServerIcon,
SettingsIcon,
WalletIcon,
} from 'lucide-react';
import Image from 'next/image';
@@ -34,6 +36,16 @@ const data = {
url: '/',
icon: LayoutDashboardIcon,
},
{
title: 'Balances',
url: '/balances',
icon: WalletIcon,
},
{
title: 'Logs',
url: '/logs',
icon: FileTextIcon,
},
{
title: 'Models',
url: '/model',
@@ -49,26 +61,6 @@ const data = {
url: '/settings',
icon: SettingsIcon,
},
// {
// title: 'Transactions',
// url: '/transactions',
// icon: ReceiptIcon,
// },
// {
// title: 'Credit',
// url: '/credits',
// icon: FolderIcon,
// },
// {
// title: 'Users',
// url: '/users',
// icon: UsersIcon,
// },
// {
// title: 'Organizations',
// url: '/organizations',
// icon: FolderIcon,
// },
],
documents: [],
};

View File

@@ -0,0 +1,79 @@
'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 type { DisplayUnit } from '@/lib/types/units';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Coins } from 'lucide-react';
export function CurrencyToggle() {
const { displayUnit, setDisplayUnit } = useCurrencyStore();
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
refetchInterval: 120_000,
staleTime: 60_000,
});
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
useEffect(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
setDisplayUnit('sat');
}
}, [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'
size='sm'
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
>
<Coins className='h-4 w-4' />
<span className='hidden sm:inline-block'>
{getLabel(displayUnit)}
</span>
<span className='uppercase sm:hidden'>{displayUnit}</span>
</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}
>
US Dollar (USD)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { convertToMsat, formatFromMsat } from '@/lib/currency';
import { Wallet, User, Coins } from 'lucide-react';
import type { DisplayUnit } from '@/lib/types/units';
interface DashboardBalanceSummaryProps {
displayUnit?: DisplayUnit;
usdPerSat?: number | null;
}
export function DashboardBalanceSummary({
displayUnit = 'sat',
usdPerSat = null,
}: DashboardBalanceSummaryProps) {
const { data } = useQuery({
queryKey: ['detailed-wallet-balance'],
queryFn: async () => {
return WalletService.getDetailedBalances();
},
refetchInterval: 30000,
});
const calculateTotals = (balances: BalanceDetail[]) => {
let totalWallet = 0;
let totalUser = 0;
let totalOwner = 0;
balances.forEach((detail) => {
if (!detail.error) {
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);
totalWallet += walletMsat;
totalUser += userMsat;
totalOwner += ownerMsat;
}
});
return { totalWallet, totalUser, totalOwner };
};
const totals = data
? calculateTotals(data)
: { totalWallet: 0, totalUser: 0, totalOwner: 0 };
const formatAmount = (msatAmount: number): string =>
formatFromMsat(msatAmount, displayUnit, usdPerSat);
const cards = [
{
title: 'Your Balance',
value: formatAmount(totals.totalOwner),
icon: Coins,
color: 'text-green-600',
bgColor: 'bg-green-100 dark:bg-green-900/20',
},
{
title: 'Total Wallet',
value: formatAmount(totals.totalWallet),
icon: Wallet,
color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
},
{
title: 'User Balance',
value: formatAmount(totals.totalUser),
icon: User,
color: 'text-purple-600',
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
},
];
return (
<div className='grid gap-4 md: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>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{card.value}</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { ErrorDetail } from '@/lib/api/services/admin';
interface ErrorDetailsTableProps {
errors: ErrorDetail[];
}
export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
if (errors.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground py-8 text-center'>
No errors found in the selected time period
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Errors ({errors.length})</CardTitle>
</CardHeader>
<CardContent>
<div className='max-h-[400px] overflow-y-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
<TableHead>Location</TableHead>
<TableHead>Request ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{errors.map((error, index) => (
<TableRow key={index}>
<TableCell className='font-mono text-xs'>
{new Date(error.timestamp).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant='destructive'>{error.error_type}</Badge>
</TableCell>
<TableCell className='max-w-md truncate'>
{error.message}
</TableCell>
<TableCell className='font-mono text-xs'>
{error.pathname}:{error.lineno}
</TableCell>
<TableCell className='font-mono text-xs'>
{error.request_id || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,750 @@
'use client';
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Bolt,
Copy,
KeyRound,
RefreshCcw,
ShieldCheck,
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 { Separator } from '@/components/ui/separator';
import { ConfigurationService } from '@/lib/api/services/configuration';
type NodeInfo = {
name: string;
description: string;
version: string;
npub?: string | null;
mints: string[];
http_url?: string | null;
onion_url?: string | null;
};
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
msats?: string;
};
const DEFAULT_BASE_URL = 'http://127.0.0.1:8000';
async function fetchNodeInfo(baseUrl: string): Promise<NodeInfo> {
const response = await fetch(`${baseUrl}/v1/info`, {
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load node info');
}
const payload = (await response.json()) as NodeInfo;
return {
...payload,
mints: Array.isArray(payload.mints) ? payload.mints : [],
};
}
async function fetchWalletInfo(
baseUrl: string,
apiKey: string
): Promise<WalletSnapshot> {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
reserved?: number;
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
};
}
function normalizeBaseUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) {
return '';
}
return trimmed.replace(/\/+$/, '');
}
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));
}
export function CheatSheet(): JSX.Element {
const [baseUrl, setBaseUrl] = useState(() =>
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
);
const [initialToken, setInitialToken] = useState('');
const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(
null
);
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
useEffect(() => {
if (!baseUrl && typeof window !== 'undefined') {
setBaseUrl(ConfigurationService.getLocalBaseUrl());
}
}, [baseUrl]);
const normalizedBaseUrl = useMemo(
() => normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL,
[baseUrl]
);
const activeApiKey = apiKeyInput.trim();
const {
data: nodeInfo,
isLoading: isInfoLoading,
isError: isInfoError,
refetch: refetchNodeInfo,
} = useQuery({
queryKey: ['node-info', normalizedBaseUrl],
queryFn: () => fetchNodeInfo(normalizedBaseUrl),
enabled: Boolean(normalizedBaseUrl),
refetchInterval: 300_000,
staleTime: 120_000,
});
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
}
if (typeof navigator === 'undefined' || !navigator.clipboard) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
}
}, []);
const handleCreateKey = useCallback(async (): Promise<void> => {
if (!initialToken.trim()) {
toast.error('Cashu token required');
return;
}
setIsCreatingKey(true);
setRefundReceipt(null);
try {
const params = new URLSearchParams({
initial_balance_token: initialToken.trim(),
});
const response = await fetch(
`${normalizedBaseUrl}/v1/balance/create?${params.toString()}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to create API key');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
};
const snapshot: WalletSnapshot = {
apiKey: payload.api_key,
balanceMsats: payload.balance ?? 0,
reservedMsats: 0,
};
setApiKeyInput(snapshot.apiKey);
setWalletInfo(snapshot);
setInitialToken('');
toast.success('API key ready');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to create API key'
);
} finally {
setIsCreatingKey(false);
}
}, [initialToken, normalizedBaseUrl]);
const handleSyncBalance = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsSyncingBalance(true);
try {
const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey);
setWalletInfo(snapshot);
toast.success('Balance synced');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to sync balance'
);
} finally {
setIsSyncingBalance(false);
}
}, [activeApiKey, normalizedBaseUrl]);
const handleTopup = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
if (!topupToken.trim()) {
toast.error('Cashu token required for top-up');
return;
}
setIsTopupLoading(true);
setRefundReceipt(null);
try {
const response = await fetch(`${normalizedBaseUrl}/v1/balance/topup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${activeApiKey}`,
},
body: JSON.stringify({ cashu_token: topupToken.trim() }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to top up');
}
const payload = (await response.json()) as { msats: number };
toast.success(`Added ${formatSats(payload.msats)} sats`);
setTopupToken('');
const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey);
setWalletInfo(snapshot);
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Top-up failed');
} finally {
setIsTopupLoading(false);
}
}, [activeApiKey, normalizedBaseUrl, topupToken]);
const handleRefund = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${normalizedBaseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${activeApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const payload = (await response.json()) as RefundReceipt;
setRefundReceipt(payload);
setWalletInfo(null);
toast.success('Refund requested');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [activeApiKey, normalizedBaseUrl]);
const handleRefreshInfo = useCallback(async (): Promise<void> => {
const result = await refetchNodeInfo();
if (result.error) {
toast.error('Unable to refresh node info');
} else {
toast.success('Node info refreshed');
}
}, [refetchNodeInfo]);
const curlSnippet = useMemo(() => {
const keyPreview = activeApiKey || 'YOUR_API_KEY';
return [
`curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`,
` -H "Authorization: Bearer ${keyPreview}"`,
' -H "Content-Type: application/json"',
" -d '{",
' "model": "openai/gpt-4o-mini",',
' "messages": [',
' {"role":"system","content":"You are Routstr."},',
' {"role":"user","content":"Ping the node"}',
' ]',
" }'",
].join('\n');
}, [activeApiKey, normalizedBaseUrl]);
const showCreateDetails =
hasInteractedCreate || initialToken.trim().length > 0;
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
const refundToken = refundReceipt?.token ?? null;
const canTopup = Boolean(activeApiKey);
return (
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
<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'>
<Button asChild size='sm' className='px-3 text-xs'>
<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'>
<Bolt className='text-primary h-4 w-4' />
Routstr cheat sheet
</div>
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
Node Identity and Cheat Sheet
</h1>
<div className='md:hidden'>
<Button asChild size='sm' className='w-full sm:w-auto'>
<a href='/login'>Admin</a>
</Button>
</div>
</section>
<section className='grid gap-4 lg:grid-cols-2'>
<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'>
/v1/info snapshot
</p>
</div>
<Button
variant='ghost'
size='sm'
className='gap-1 text-xs'
onClick={handleRefreshInfo}
disabled={isInfoLoading}
>
<RefreshCcw className='h-4 w-4' />
Refresh
</Button>
</CardHeader>
<CardContent className='space-y-4'>
{isInfoLoading && (
<p className='text-muted-foreground text-sm'>
Loading node profile
</p>
)}
{isInfoError && !isInfoLoading && (
<p className='text-destructive text-sm'>
Unable to reach /v1/info at {normalizedBaseUrl}
</p>
)}
{nodeInfo && (
<>
<div className='space-y-2'>
<p className='text-2xl font-medium'>{nodeInfo.name}</p>
<p className='text-muted-foreground text-sm'>
{nodeInfo.description}
</p>
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
Version
</dt>
<dd className='text-base font-medium'>
{nodeInfo.version}
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
{nodeInfo.http_url || normalizedBaseUrl}
</dd>
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
{nodeInfo.onion_url}
</dd>
</div>
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
npub
</dt>
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
{nodeInfo.npub}
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(nodeInfo.npub ?? '')}
>
<Copy className='h-4 w-4' />
</Button>
</dd>
</div>
)}
</dl>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
{nodeInfo.mints.length ? (
nodeInfo.mints.map((mint) => (
<Badge
key={mint}
variant='secondary'
className='font-mono text-xs'
>
{mint}
</Badge>
))
) : (
<p className='text-muted-foreground text-sm'>
No mint list published
</p>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
<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'>
curl-ready
</span>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center gap-2'>
<Input
value={baseUrl}
placeholder={normalizedBaseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
className='text-sm'
/>
<Button
variant='outline'
size='icon'
className='h-9 w-9'
onClick={() => handleCopy(normalizedBaseUrl)}
>
<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'>
{curlSnippet}
</pre>
</div>
<div className='flex gap-2'>
<Button
variant='secondary'
className='gap-2'
onClick={() => handleCopy(curlSnippet)}
>
<Copy className='h-4 w-4' />
Copy curl
</Button>
<Button variant='outline' asChild className='gap-2'>
<a
href='https://docs.routstr.com'
target='_blank'
rel='noreferrer'
>
<Terminal className='h-4 w-4' />
Full docs
</a>
</Button>
</div>
</CardContent>
</Card>
</section>
<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'>
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'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
)}
</header>
<Textarea
value={initialToken}
onChange={(event) => setInitialToken(event.target.value)}
placeholder='cashuA1...'
rows={showCreateDetails ? 4 : 2}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleCreateKey}
disabled={isCreatingKey}
className='gap-2'
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-muted-foreground text-xs'>
Redeems instantly and returns <code>sk-</code> key.
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(event) => {
setApiKeyInput(event.target.value);
setWalletInfo(null);
}}
placeholder='sk-...'
className='font-mono text-sm'
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10'
onClick={() => handleCopy(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isSyncingBalance || !activeApiKey}
>
<RefreshCcw className='h-4 w-4' />
Sync
</Button>
</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>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>3 · Top up</span>
{showTopupDetails && (
<span
className={canTopup ? 'text-primary' : 'text-destructive'}
>
{canTopup ? 'Ready to redeem' : 'Paste API key first'}
</span>
)}
</header>
<Textarea
value={topupToken}
onChange={(event) => setTopupToken(event.target.value)}
placeholder='cashuB1...'
rows={showTopupDetails ? 3 : 1}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedTopup(true)}
disabled={!canTopup}
/>
{showTopupDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleTopup}
disabled={isTopupLoading || !canTopup}
variant='outline'
className='gap-2'
>
{isTopupLoading ? 'Topping up…' : 'Top up this key'}
</Button>
<span className='text-muted-foreground text-xs'>
{canTopup ? (
<>
Adds balance to the same <code>sk-</code> token.
</>
) : (
'Enter your sk- key above to unlock top ups.'
)}
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>4 · Refund</span>
{refundReceipt && <span className='text-primary'>Done</span>}
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding}
variant='destructive'
className='gap-2'
>
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
{refundToken && (
<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'>
<span>Cashu refund token</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(refundToken)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={refundToken}
readOnly
rows={4}
className='font-mono text-xs'
/>
</div>
)}
</section>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { ModelRevenueData } from '@/lib/api/services/admin';
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { formatFromMsat, convertToMsat } from '@/lib/currency';
interface RevenueByModelTableProps {
models: ModelRevenueData[];
totalRevenue: number;
}
export function RevenueByModelTable({
models,
totalRevenue,
}: RevenueByModelTableProps) {
const { displayUnit } = useCurrencyStore();
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
refetchInterval: 120_000,
staleTime: 60_000,
});
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
const formatAmount = (sats: number) =>
formatFromMsat(convertToMsat(sats, 'sat'), displayUnit, usdPerSat);
return (
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className='text-muted-foreground text-sm'>
Total Revenue:{' '}
<span className='text-foreground font-mono font-medium'>
{formatAmount(totalRevenue)}
</span>
</p>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className='text-right'>Requests</TableHead>
<TableHead className='text-right'>Successful</TableHead>
<TableHead className='text-right'>Failed</TableHead>
<TableHead className='text-right'>Revenue</TableHead>
<TableHead className='w-[100px]'>Share</TableHead>
<TableHead className='text-right'>Refunds</TableHead>
<TableHead className='text-right'>Net Revenue</TableHead>
<TableHead className='text-right'>Avg/Request</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className='text-muted-foreground text-center'
>
No model data available
</TableCell>
</TableRow>
) : (
models.map((model) => {
const share =
totalRevenue > 0
? (model.revenue_sats / totalRevenue) * 100
: 0;
return (
<TableRow key={model.model}>
<TableCell className='font-medium'>{model.model}</TableCell>
<TableCell className='text-right font-mono'>
{model.requests}
</TableCell>
<TableCell className='text-right font-mono text-green-600'>
{model.successful}
</TableCell>
<TableCell className='text-right font-mono text-red-600'>
{model.failed}
</TableCell>
<TableCell className='text-right font-mono'>
{formatAmount(model.revenue_sats)}
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Progress value={share} className='h-2' />
<span className='text-muted-foreground w-8 text-right text-xs'>
{share.toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell className='text-right font-mono text-red-500'>
{formatAmount(model.refunds_sats)}
</TableCell>
<TableCell className='text-right font-mono font-semibold'>
{formatAmount(model.net_revenue_sats)}
</TableCell>
<TableCell className='text-muted-foreground text-right font-mono'>
{formatAmount(model.avg_revenue_per_request)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
import { adminLogout } from '@/lib/api/services/auth';
import { toast } from 'sonner';
import { ThemeToggle } from '@/components/theme-toggle';
import { CurrencyToggle } from '@/components/currency-toggle';
export function SiteHeader() {
const router = useRouter();
@@ -35,6 +36,7 @@ export function SiteHeader() {
<h1 className='text-base font-medium lg:hidden'>Routstr Node</h1>
</div>
<div className='flex items-center gap-2'>
<CurrencyToggle />
<ThemeToggle />
<Button
variant='ghost'

View File

@@ -0,0 +1,72 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'
),
button_next: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'
),
month_grid: 'w-full border-collapse space-y-1',
weekdays: 'flex',
weekday:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
week: 'flex w-full mt-2',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_button: 'h-9 w-9 p-0 font-normal',
range_end: 'day-range-end',
selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
today: 'bg-accent text-accent-foreground',
outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
disabled: 'text-muted-foreground opacity-50',
range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
hidden: 'invisible',
...classNames,
}}
components={{
Chevron: ({ orientation }) => {
if (orientation === 'left') {
return <ChevronLeft className='h-4 w-4' />;
}
return <ChevronRight className='h-4 w-4' />;
},
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View File

@@ -0,0 +1,118 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Legend,
CartesianGrid,
} from 'recharts';
interface UsageMetricsChartProps {
data: Array<Record<string, unknown> & { timestamp: string }>;
title: string;
dataKeys: Array<{
key: string;
name: string;
color: string;
}>;
}
export function UsageMetricsChart({
data,
title,
dataKeys,
}: UsageMetricsChartProps) {
const formattedData = data.map((item) => ({
...item,
time: new Date(item.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}));
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width='100%' height={300}>
<AreaChart data={formattedData}>
<defs>
{dataKeys.map((dataKey) => (
<linearGradient
key={dataKey.key}
id={`color${dataKey.key}`}
x1='0'
y1='0'
x2='0'
y2='1'
>
<stop
offset='5%'
stopColor={dataKey.color}
stopOpacity={0.3}
/>
<stop
offset='95%'
stopColor={dataKey.color}
stopOpacity={0}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray='3 3' className='stroke-muted/30' />
<XAxis
dataKey='time'
className='text-xs'
tick={{ fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
minTickGap={32}
/>
<YAxis
className='text-xs'
tick={{ fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
width={40}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
itemStyle={{ fontSize: '12px' }}
labelStyle={{
fontSize: '12px',
color: 'hsl(var(--muted-foreground))',
marginBottom: '8px',
}}
/>
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '16px' }} />
{dataKeys.map((dataKey) => (
<Area
key={dataKey.key}
type='monotone'
dataKey={dataKey.key}
stroke={dataKey.color}
fillOpacity={1}
fill={`url(#color${dataKey.key})`}
name={dataKey.name}
strokeWidth={2}
animationDuration={1000}
/>
))}
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageSummary } from '@/lib/api/services/admin';
import {
CheckCircle2,
XCircle,
AlertTriangle,
Activity,
Database,
CreditCard,
TrendingUp,
DollarSign,
TrendingDown,
Coins,
} from 'lucide-react';
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { formatFromMsat } from '@/lib/currency';
interface UsageSummaryCardsProps {
summary: UsageSummary;
}
export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
const { displayUnit } = useCurrencyStore();
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
refetchInterval: 120_000,
staleTime: 60_000,
});
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
const formatAmount = (msat: number) =>
formatFromMsat(msat, displayUnit, usdPerSat);
const cards = [
{
title: 'Total Requests',
value: summary.total_requests.toLocaleString(),
icon: Activity,
color: 'text-blue-500',
},
{
title: 'Successful Completions',
value: summary.successful_chat_completions.toLocaleString(),
icon: CheckCircle2,
color: 'text-green-500',
},
{
title: 'Revenue',
value: formatAmount(summary.revenue_msats),
icon: Coins,
color: 'text-green-600',
},
{
title: 'Net Revenue',
value: formatAmount(summary.net_revenue_msats),
icon: DollarSign,
color: 'text-emerald-600',
},
{
title: 'Refunds',
value: formatAmount(summary.refunds_msats),
icon: TrendingDown,
color: 'text-red-500',
},
{
title: 'Avg Revenue/Request',
value: formatAmount(summary.avg_revenue_per_request_msats),
icon: CreditCard,
color: 'text-cyan-500',
},
{
title: 'Success Rate',
value: `${summary.success_rate.toFixed(1)}%`,
icon: TrendingUp,
color: 'text-emerald-500',
},
{
title: 'Refund Rate',
value: `${summary.refund_rate.toFixed(1)}%`,
icon: XCircle,
color: 'text-orange-500',
},
{
title: 'Failed Requests',
value: summary.failed_requests.toLocaleString(),
icon: XCircle,
color: 'text-red-400',
},
{
title: 'Errors',
value: summary.total_errors.toLocaleString(),
icon: AlertTriangle,
color: 'text-orange-500',
},
{
title: 'Unique Models',
value: summary.unique_models_count.toLocaleString(),
icon: Database,
color: 'text-purple-500',
},
{
title: 'Upstream Errors',
value: summary.upstream_errors.toLocaleString(),
icon: AlertTriangle,
color: 'text-yellow-500',
},
];
return (
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-4'>
{cards.map((card) => (
<Card key={card.title} className='hover:bg-muted/50 transition-colors'>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
{card.title}
</CardTitle>
<div className={`bg-secondary rounded-full p-2`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold tracking-tight'>
{card.value}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -67,7 +67,9 @@ export const AdminModelSchema = z.object({
pricing: AdminModelPricingSchema.or(z.record(z.any())),
per_request_limits: z.record(z.any()).nullable().optional(),
top_provider: z.record(z.any()).nullable().optional(),
upstream_provider_id: z.number().nullable().optional(),
upstream_provider_id: z.union([z.string(), z.number()]).nullable().optional(),
canonical_slug: z.string().nullable().optional(),
alias_ids: z.array(z.string()).nullable().optional(),
enabled: z.boolean().default(true),
});
@@ -800,15 +802,69 @@ export class AdminService {
return await apiClient.post<{ ok: boolean }>('/admin/api/logout', {});
}
static async getLogs(
date?: string,
level?: string,
requestId?: string,
search?: string,
limit: number = 100
): Promise<LogResponse> {
const params = new URLSearchParams();
if (date) params.append('date', date);
if (level) params.append('level', level);
if (requestId) params.append('request_id', requestId);
if (search) params.append('search', search);
params.append('limit', limit.toString());
return await apiClient.get<LogResponse>(
`/admin/api/logs?${params.toString()}`
);
}
static async getLogDates(): Promise<{ dates: string[] }> {
return await apiClient.get<{ dates: string[] }>('/admin/api/logs/dates');
}
static async getTemporaryBalances(): Promise<TemporaryBalance[]> {
return await apiClient.get<TemporaryBalance[]>(
'/admin/api/temporary-balances'
);
}
static async createProviderAccountByType(
providerType: string
): Promise<{
static async getUsageMetrics(
interval: number = 15,
hours: number = 24
): Promise<UsageMetrics> {
return await apiClient.get<UsageMetrics>(
`/admin/api/usage/metrics?interval=${interval}&hours=${hours}`
);
}
static async getUsageSummary(hours: number = 24): Promise<UsageSummary> {
return await apiClient.get<UsageSummary>(
`/admin/api/usage/summary?hours=${hours}`
);
}
static async getErrorDetails(
hours: number = 24,
limit: number = 100
): Promise<ErrorDetails> {
return await apiClient.get<ErrorDetails>(
`/admin/api/usage/error-details?hours=${hours}&limit=${limit}`
);
}
static async getRevenueByModel(
hours: number = 24,
limit: number = 20
): Promise<RevenueByModel> {
return await apiClient.get<RevenueByModel>(
`/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
);
}
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
message: string;
@@ -825,7 +881,11 @@ export class AdminService {
static async initiateProviderTopup(
providerId: number,
amount: number
): Promise<{ ok: boolean; topup_data: Record<string, unknown>; message: string }> {
): Promise<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}> {
return await apiClient.post<{
ok: boolean;
topup_data: Record<string, unknown>;
@@ -845,12 +905,13 @@ export class AdminService {
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
}
static async getProviderBalance(
providerId: number
): Promise<{ ok: boolean; balance_data: Record<string, unknown> }> {
static async getProviderBalance(providerId: number): Promise<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;
}> {
return await apiClient.get<{
ok: boolean;
balance_data: Record<string, unknown>;
balance_data: number | null | Record<string, unknown>;
}>(`/admin/api/upstream-providers/${providerId}/balance`);
}
}
@@ -865,3 +926,99 @@ export const TemporaryBalanceSchema = z.object({
});
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
export interface UsageMetricData {
timestamp: string;
total_requests: number;
successful_chat_completions: number;
failed_requests: number;
errors: number;
warnings: number;
payment_processed: number;
upstream_errors: number;
revenue_msats: number;
refunds_msats: number;
[key: string]: unknown;
}
export interface UsageMetrics {
metrics: UsageMetricData[];
interval_minutes: number;
hours_back: number;
total_buckets: number;
}
export interface UsageSummary {
total_entries: number;
total_requests: number;
successful_chat_completions: number;
failed_requests: number;
total_errors: number;
total_warnings: number;
payment_processed: number;
upstream_errors: number;
unique_models_count: number;
unique_models: string[];
error_types: Record<string, number>;
success_rate: number;
revenue_msats: number;
refunds_msats: number;
revenue_sats: number;
refunds_sats: number;
net_revenue_msats: number;
net_revenue_sats: number;
avg_revenue_per_request_msats: number;
refund_rate: number;
}
export interface ErrorDetail {
timestamp: string;
message: string;
error_type: string;
pathname: string;
lineno: number;
request_id: string;
}
export interface ErrorDetails {
errors: ErrorDetail[];
total_count: number;
}
export interface ModelRevenueData {
model: string;
revenue_sats: number;
refunds_sats: number;
net_revenue_sats: number;
requests: number;
successful: number;
failed: number;
avg_revenue_per_request: number;
}
export interface RevenueByModel {
models: ModelRevenueData[];
total_revenue_sats: number;
total_models: number;
}
export interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname?: string;
lineno?: number;
request_id?: string;
[key: string]: unknown;
}
export interface LogResponse {
logs: LogEntry[];
total: number;
date: string | null;
level: string | null;
request_id: string | null;
search: string | null;
limit: number;
}

View File

@@ -9,8 +9,10 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
useEffect(() => {
const publicPaths = ['/login', '/_register', '/unauthorized'];
const isPublicPath = publicPaths.some((path) => pathname.startsWith(path));
const publicPaths = ['/', '/login', '/unauthorized'];
const isPublicPath = publicPaths.some((path) =>
path === '/' ? pathname === '/' : pathname.startsWith(path)
);
if (!isPublicPath && !ConfigurationService.isTokenValid()) {
router.push('/login');

View File

@@ -25,7 +25,8 @@ export function formatFromMsat(
if (displayUnit === 'sat') {
const sats = amountMsat / 1000;
return `${sats.toLocaleString()} sats`;
// Format as integer for sats
return `${Math.floor(sats).toLocaleString()} sats`;
}
if (usdPerSat === null) {
@@ -34,12 +35,11 @@ export function formatFromMsat(
const sats = amountMsat / 1000;
const usd = sats * usdPerSat;
const precision = Math.abs(usd) >= 1 ? 2 : 4;
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: precision,
maximumFractionDigits: Math.max(precision, 6),
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return formatter.format(usd);
}

20
ui/lib/stores/currency.ts Normal file
View File

@@ -0,0 +1,20 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { DisplayUnit } from '@/lib/types/units';
interface CurrencyState {
displayUnit: DisplayUnit;
setDisplayUnit: (unit: DisplayUnit) => void;
}
export const useCurrencyStore = create<CurrencyState>()(
persist(
(set) => ({
displayUnit: 'sat',
setDisplayUnit: (unit) => set({ displayUnit: unit }),
}),
{
name: 'currency-storage',
}
)
);

6
uv.lock generated
View File

@@ -1878,7 +1878,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.2.0c0"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1890,6 +1890,7 @@ dependencies = [
{ name = "marshmallow" },
{ name = "mdurl" },
{ name = "nostr" },
{ name = "openai" },
{ name = "pillow" },
{ name = "python-json-logger" },
{ name = "secp256k1" },
@@ -1923,7 +1924,8 @@ requires-dist = [
{ name = "marshmallow", specifier = ">=3.13,<4.0" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "nostr", specifier = ">=0.0.2" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "openai", specifier = ">=1.98.0" },
{ name = "pillow", specifier = ">=10" },
{ name = "python-json-logger", specifier = ">=2.0.0" },
{ name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },
{ name = "sqlmodel", specifier = ">=0.0.24" },

1
vendor/cdk vendored

Submodule vendor/cdk deleted from 52d796e9fe

1
vendor/cdk-python vendored

Submodule vendor/cdk-python deleted from 915c6966b0