mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 02:54:37 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3465a44d0e | ||
|
|
a4259af38f | ||
|
|
0d07dd0cdb | ||
|
|
24015ebec1 | ||
|
|
3bc38937e8 | ||
|
|
9229b87b70 | ||
|
|
367265b9fe | ||
|
|
0b3ccb5fb0 | ||
|
|
dbd43f52fb | ||
|
|
39657ed64f | ||
|
|
493b4f0f1f | ||
|
|
ca7e8bec71 | ||
|
|
c4cc09d61e | ||
|
|
fad792068e | ||
|
|
21d363f6aa | ||
|
|
5e21f6ccbc | ||
|
|
1e2d130022 | ||
|
|
1e21dce735 | ||
|
|
b7603dcf69 | ||
|
|
7dccfa745f | ||
|
|
54d5118980 | ||
|
|
7723ab4a95 | ||
|
|
86c022d8db | ||
|
|
21ae22abec | ||
|
|
3a939d0dd1 | ||
|
|
50eabafa57 | ||
|
|
d192a6a6b4 | ||
|
|
7d829af681 | ||
|
|
57bf1b68d9 | ||
|
|
00d0415518 | ||
|
|
e8585b276f | ||
|
|
4b5e911435 | ||
|
|
761aabfec3 | ||
|
|
f0c45a7ce4 | ||
|
|
fc8ccf63ba | ||
|
|
eeb70e4ee5 | ||
|
|
a3b410b467 | ||
|
|
9e9bc5bff8 | ||
|
|
bdf0e2c192 | ||
|
|
6d780ef96d | ||
|
|
b70b94b9b4 | ||
|
|
334453f934 | ||
|
|
5a4ba60072 | ||
|
|
d41c214d9e | ||
|
|
ec0fcfb48b |
@@ -59,25 +59,30 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: "npm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: ui/package-lock.json
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install UI dependencies
|
- name: Install UI dependencies
|
||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run UI format check
|
- name: Run UI format check
|
||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
run: npm run format-check
|
run: pnpm run format-check
|
||||||
|
|
||||||
- name: Run UI linting
|
- name: Run UI linting
|
||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
run: npm run lint
|
run: pnpm run lint
|
||||||
|
|
||||||
- name: Run UI build
|
- name: Run UI build
|
||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
run: npm run build
|
run: pnpm run build
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "routstr"
|
name = "routstr"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+50
-20
@@ -441,6 +441,29 @@ async def adjust_payment_for_tokens(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def release_reservation_only() -> None:
|
||||||
|
"""Fallback to release reservation without charging when main update fails."""
|
||||||
|
try:
|
||||||
|
release_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.values(reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost)
|
||||||
|
)
|
||||||
|
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||||
|
await session.commit()
|
||||||
|
logger.warning(
|
||||||
|
"Released reservation without charging (fallback)",
|
||||||
|
extra={
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"deducted_max_cost": deducted_max_cost,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to release reservation in fallback",
|
||||||
|
extra={"error": str(e), "key_hash": key.hashed_key[:8] + "..."},
|
||||||
|
)
|
||||||
|
|
||||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||||
case MaxCostData() as cost:
|
case MaxCostData() as cost:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -465,7 +488,7 @@ async def adjust_payment_for_tokens(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to finalize max-cost payment - insufficient reserved balance",
|
"Failed to finalize max-cost payment - retrying reservation release",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
@@ -474,6 +497,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await release_reservation_only()
|
||||||
else:
|
else:
|
||||||
await session.refresh(key)
|
await session.refresh(key)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -568,13 +592,14 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to finalize additional charge (concurrent operation)",
|
"Failed to finalize additional charge - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"attempted_charge": total_cost_msats,
|
"attempted_charge": total_cost_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await release_reservation_only()
|
||||||
else:
|
else:
|
||||||
# Refund some of the base cost
|
# Refund some of the base cost
|
||||||
refund = abs(cost_difference)
|
refund = abs(cost_difference)
|
||||||
@@ -603,7 +628,7 @@ async def adjust_payment_for_tokens(
|
|||||||
|
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to finalize payment - insufficient reserved balance",
|
"Failed to finalize payment - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
@@ -612,28 +637,27 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Still return the cost data even if we couldn't properly finalize
|
await release_reservation_only()
|
||||||
# The reservation was already made, so the user has paid
|
else:
|
||||||
|
cost.total_msats = total_cost_msats
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
cost.total_msats = total_cost_msats
|
logger.info(
|
||||||
await session.refresh(key)
|
"Refund processed successfully",
|
||||||
|
extra={
|
||||||
logger.info(
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"Refund processed successfully",
|
"refunded_amount": refund,
|
||||||
extra={
|
"new_balance": key.balance,
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"final_cost": cost.total_msats,
|
||||||
"refunded_amount": refund,
|
"model": model,
|
||||||
"new_balance": key.balance,
|
},
|
||||||
"final_cost": cost.total_msats,
|
)
|
||||||
"model": model,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return cost.dict()
|
return cost.dict()
|
||||||
|
|
||||||
case CostDataError() as error:
|
case CostDataError() as error:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Cost calculation error during payment adjustment",
|
"Cost calculation error during payment adjustment - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -641,6 +665,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"error_code": error.code,
|
"error_code": error.code,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await release_reservation_only()
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -652,7 +677,12 @@ async def adjust_payment_for_tokens(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Fallback return to satisfy type checker; execution should not reach here
|
# Fallback: should not reach here, but release reservation just in case
|
||||||
|
logger.error(
|
||||||
|
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
|
||||||
|
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||||
|
)
|
||||||
|
await release_reservation_only()
|
||||||
return {
|
return {
|
||||||
"base_msats": deducted_max_cost,
|
"base_msats": deducted_max_cost,
|
||||||
"input_msats": 0,
|
"input_msats": 0,
|
||||||
|
|||||||
+2
-1
@@ -154,7 +154,8 @@ async def refund_wallet_endpoint(
|
|||||||
return cached
|
return cached
|
||||||
|
|
||||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||||
remaining_balance_msats: int = key.balance
|
|
||||||
|
remaining_balance_msats: int = key.total_balance
|
||||||
|
|
||||||
if key.refund_currency == "sat":
|
if key.refund_currency == "sat":
|
||||||
remaining_balance = remaining_balance_msats // 1000
|
remaining_balance = remaining_balance_msats // 1000
|
||||||
|
|||||||
@@ -3080,6 +3080,9 @@ async def get_logs_api(
|
|||||||
level: str | None = None,
|
level: str | None = None,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
|
status_codes: str | None = Query(None, description="Comma-separated status codes"),
|
||||||
|
methods: str | None = Query(None, description="Comma-separated HTTP methods"),
|
||||||
|
endpoints: str | None = Query(None, description="Comma-separated endpoints"),
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""
|
"""
|
||||||
@@ -3090,16 +3093,32 @@ async def get_logs_api(
|
|||||||
level: Filter by log level
|
level: Filter by log level
|
||||||
request_id: Filter by request ID
|
request_id: Filter by request ID
|
||||||
search: Search text in message and name fields (case-insensitive)
|
search: Search text in message and name fields (case-insensitive)
|
||||||
|
status_codes: Comma-separated list of HTTP status codes
|
||||||
|
methods: Comma-separated list of HTTP methods
|
||||||
|
endpoints: Comma-separated list of endpoints
|
||||||
limit: Maximum number of entries to return
|
limit: Maximum number of entries to return
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing logs and filter metadata
|
Dict containing logs and filter metadata
|
||||||
"""
|
"""
|
||||||
|
status_code_list = None
|
||||||
|
if status_codes:
|
||||||
|
try:
|
||||||
|
status_code_list = [int(s.strip()) for s in status_codes.split(",")]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
method_list = [m.strip() for m in methods.split(",")] if methods else None
|
||||||
|
endpoint_list = [e.strip() for e in endpoints.split(",")] if endpoints else None
|
||||||
|
|
||||||
log_entries = log_manager.search_logs(
|
log_entries = log_manager.search_logs(
|
||||||
date=date,
|
date=date,
|
||||||
level=level,
|
level=level,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
search_text=search,
|
search_text=search,
|
||||||
|
status_codes=status_code_list,
|
||||||
|
methods=method_list,
|
||||||
|
endpoints=endpoint_list,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3110,6 +3129,9 @@ async def get_logs_api(
|
|||||||
"level": level,
|
"level": level,
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"search": search,
|
"search": search,
|
||||||
|
"status_codes": status_codes,
|
||||||
|
"methods": methods,
|
||||||
|
"endpoints": endpoints,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -6,7 +6,7 @@ from typing import AsyncGenerator
|
|||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||||
from sqlmodel import Field, Relationship, SQLModel, func, select
|
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from .logging import get_logger
|
from .logging import get_logger
|
||||||
@@ -53,6 +53,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
|||||||
return self.balance - self.reserved_balance
|
return self.balance - self.reserved_balance
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
||||||
|
logger.info("Resetting all reserved balances to 0")
|
||||||
|
stmt = update(ApiKey).values(reserved_balance=0)
|
||||||
|
await session.exec(stmt) # type: ignore[call-overload]
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Reserved balances reset successfully")
|
||||||
|
|
||||||
|
|
||||||
class ModelRow(SQLModel, table=True): # type: ignore
|
class ModelRow(SQLModel, table=True): # type: ignore
|
||||||
__tablename__ = "models"
|
__tablename__ = "models"
|
||||||
id: str = Field(primary_key=True)
|
id: str = Field(primary_key=True)
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ class LogManager:
|
|||||||
level: str | None = None,
|
level: str | None = None,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
search_text: str | None = None,
|
search_text: str | None = None,
|
||||||
|
status_codes: list[int] | None = None,
|
||||||
|
methods: list[str] | None = None,
|
||||||
|
endpoints: list[str] | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -134,7 +137,13 @@ class LogManager:
|
|||||||
|
|
||||||
for log_data in iterator:
|
for log_data in iterator:
|
||||||
if not self._matches_filters(
|
if not self._matches_filters(
|
||||||
log_data, level, request_id, search_text_lower
|
log_data,
|
||||||
|
level,
|
||||||
|
request_id,
|
||||||
|
search_text_lower,
|
||||||
|
status_codes,
|
||||||
|
methods,
|
||||||
|
endpoints,
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -153,6 +162,9 @@ class LogManager:
|
|||||||
level: str | None,
|
level: str | None,
|
||||||
request_id: str | None,
|
request_id: str | None,
|
||||||
search_text_lower: str | None,
|
search_text_lower: str | None,
|
||||||
|
status_codes: list[int] | None = None,
|
||||||
|
methods: list[str] | None = None,
|
||||||
|
endpoints: list[str] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if level and log_data.get("levelname", "").upper() != level.upper():
|
if level and log_data.get("levelname", "").upper() != level.upper():
|
||||||
return False
|
return False
|
||||||
@@ -160,6 +172,36 @@ class LogManager:
|
|||||||
if request_id and log_data.get("request_id") != request_id:
|
if request_id and log_data.get("request_id") != request_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if status_codes:
|
||||||
|
entry_status = log_data.get("status_code")
|
||||||
|
if entry_status is not None:
|
||||||
|
try:
|
||||||
|
if int(entry_status) not in status_codes:
|
||||||
|
return False
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if methods:
|
||||||
|
entry_method = log_data.get("method", "").upper()
|
||||||
|
if entry_method not in [m.upper() for m in methods]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if endpoints:
|
||||||
|
entry_path = log_data.get("path", "")
|
||||||
|
matched = False
|
||||||
|
for endpoint in endpoints:
|
||||||
|
clean_endpoint = endpoint.lstrip("/")
|
||||||
|
if entry_path.startswith(clean_endpoint):
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if clean_endpoint in entry_path:
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
return False
|
||||||
|
|
||||||
if search_text_lower:
|
if search_text_lower:
|
||||||
message = str(log_data.get("message", "")).lower()
|
message = str(log_data.get("message", "")).lower()
|
||||||
name = str(log_data.get("name", "")).lower()
|
name = str(log_data.get("name", "")).lower()
|
||||||
|
|||||||
+151
-6
@@ -1,11 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from ..balance import balance_router, deprecated_wallet_router
|
from ..balance import balance_router, deprecated_wallet_router
|
||||||
@@ -25,16 +27,15 @@ from .logging import get_logger, setup_logging
|
|||||||
from .middleware import LoggingMiddleware
|
from .middleware import LoggingMiddleware
|
||||||
from .settings import SettingsService
|
from .settings import SettingsService
|
||||||
from .settings import settings as global_settings
|
from .settings import settings as global_settings
|
||||||
from .ui import setup_ui
|
|
||||||
|
|
||||||
# Initialize logging first
|
# Initialize logging first
|
||||||
setup_logging()
|
setup_logging()
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
if os.getenv("VERSION_SUFFIX") is not None:
|
if os.getenv("VERSION_SUFFIX") is not None:
|
||||||
__version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}"
|
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
|
||||||
else:
|
else:
|
||||||
__version__ = "0.2.1"
|
__version__ = "0.2.2"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -60,6 +61,15 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# Initialize application settings (env -> computed -> DB precedence)
|
# Initialize application settings (env -> computed -> DB precedence)
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
s = await SettingsService.initialize(session)
|
s = await SettingsService.initialize(session)
|
||||||
|
if s.reset_reserved_balance_on_startup:
|
||||||
|
from .db import reset_all_reserved_balances
|
||||||
|
|
||||||
|
await reset_all_reserved_balances(session)
|
||||||
|
|
||||||
|
if not s.admin_password:
|
||||||
|
logger.warning(
|
||||||
|
f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password."
|
||||||
|
)
|
||||||
|
|
||||||
# Apply app metadata from settings
|
# Apply app metadata from settings
|
||||||
try:
|
try:
|
||||||
@@ -189,8 +199,143 @@ async def providers() -> RedirectResponse:
|
|||||||
return RedirectResponse("/v1/providers/")
|
return RedirectResponse("/v1/providers/")
|
||||||
|
|
||||||
|
|
||||||
# Setup UI routes (local serving or proxy fallback)
|
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
||||||
setup_ui(app, __version__)
|
|
||||||
|
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||||
|
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
||||||
|
|
||||||
|
app.mount(
|
||||||
|
"/_next",
|
||||||
|
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||||
|
name="next-static",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def serve_root_ui() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "index.html")
|
||||||
|
|
||||||
|
# Add explicit route for /index.txt to redirect to /
|
||||||
|
@app.get("/index.txt", include_in_schema=False)
|
||||||
|
async def redirect_index_txt() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/")
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin_redirect() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "index.html")
|
||||||
|
|
||||||
|
@app.get("/dashboard", include_in_schema=False)
|
||||||
|
async def serve_dashboard_ui() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "index.html")
|
||||||
|
|
||||||
|
@app.get("/login", include_in_schema=False)
|
||||||
|
async def serve_login_ui() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||||
|
|
||||||
|
# Add explicit route for /login/index.txt to redirect to /login
|
||||||
|
@app.get("/login/index.txt", include_in_schema=False)
|
||||||
|
async def redirect_login_index_txt() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/login")
|
||||||
|
|
||||||
|
@app.get("/model", include_in_schema=False)
|
||||||
|
async def serve_models_ui() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||||
|
|
||||||
|
# Add explicit route for /model/index.txt to redirect to /model
|
||||||
|
@app.get("/model/index.txt", include_in_schema=False)
|
||||||
|
async def redirect_model_index_txt() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/model")
|
||||||
|
|
||||||
|
@app.get("/providers", include_in_schema=False)
|
||||||
|
async def serve_providers_ui() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||||
|
|
||||||
|
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||||
|
@app.get("/providers/index.txt", include_in_schema=False)
|
||||||
|
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/providers")
|
||||||
|
|
||||||
|
@app.get("/settings", include_in_schema=False)
|
||||||
|
async def serve_settings_ui() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||||
|
|
||||||
|
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||||
|
@app.get("/settings/index.txt", include_in_schema=False)
|
||||||
|
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/settings")
|
||||||
|
|
||||||
|
@app.get("/transactions", include_in_schema=False)
|
||||||
|
async def serve_transactions_ui() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||||
|
|
||||||
|
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||||
|
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||||
|
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||||
|
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/unauthorized")
|
||||||
|
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def serve_favicon() -> FileResponse:
|
||||||
|
icon_path = UI_DIST_PATH / "icon.ico"
|
||||||
|
if icon_path.exists():
|
||||||
|
return FileResponse(icon_path)
|
||||||
|
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
||||||
|
|
||||||
|
@app.get("/icon.ico", include_in_schema=False)
|
||||||
|
async def serve_icon() -> FileResponse:
|
||||||
|
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||||
|
|
||||||
|
app.mount(
|
||||||
|
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def root_fallback() -> dict:
|
||||||
|
return {
|
||||||
|
"name": global_settings.name,
|
||||||
|
"description": global_settings.description,
|
||||||
|
"version": __version__,
|
||||||
|
"status": "running",
|
||||||
|
"ui": "not available",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
app.include_router(models_router)
|
app.include_router(models_router)
|
||||||
|
|||||||
@@ -54,12 +54,15 @@ class Settings(BaseSettings):
|
|||||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
||||||
|
reset_reserved_balance_on_startup: bool = Field(
|
||||||
|
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
|
||||||
|
) # deactivate in horizontal scaling setups
|
||||||
|
|
||||||
# Network
|
# Network
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
||||||
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
|
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
|
||||||
providers_refresh_interval_seconds: int = Field(
|
providers_refresh_interval_seconds: int = Field(
|
||||||
default=300, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
||||||
)
|
)
|
||||||
pricing_refresh_interval_seconds: int = Field(
|
pricing_refresh_interval_seconds: int = Field(
|
||||||
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
|
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
|
||||||
@@ -89,9 +92,6 @@ class Settings(BaseSettings):
|
|||||||
# Discovery
|
# Discovery
|
||||||
relays: list[str] = Field(default_factory=list, env="RELAYS")
|
relays: list[str] = Field(default_factory=list, env="RELAYS")
|
||||||
|
|
||||||
# Cloud UI
|
|
||||||
fallback_ui_url: str = Field(default="https://api.routstr.com", env="FALLBACK_UI_URL")
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_primary_mint(cashu_mints: list[str]) -> str:
|
def _compute_primary_mint(cashu_mints: list[str]) -> str:
|
||||||
return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin"
|
return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin"
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from .logging import get_logger
|
|
||||||
from .settings import settings as global_settings
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_ui(app: FastAPI, version: str) -> None:
|
|
||||||
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
|
||||||
|
|
||||||
# Check if we have a valid local UI build
|
|
||||||
# We require at least the index.html to be present because the directory might exist
|
|
||||||
# but be empty (e.g. Docker volume mount before build completes)
|
|
||||||
has_local_ui = (
|
|
||||||
UI_DIST_PATH.exists()
|
|
||||||
and UI_DIST_PATH.is_dir()
|
|
||||||
and (UI_DIST_PATH / "index.html").exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
if has_local_ui:
|
|
||||||
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
|
||||||
|
|
||||||
app.mount(
|
|
||||||
"/_next",
|
|
||||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
|
||||||
name="next-static",
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
|
||||||
async def serve_root_ui() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "index.html")
|
|
||||||
|
|
||||||
# Add explicit route for /index.txt to redirect to /
|
|
||||||
@app.get("/index.txt", include_in_schema=False)
|
|
||||||
async def redirect_index_txt() -> RedirectResponse:
|
|
||||||
return RedirectResponse("/")
|
|
||||||
|
|
||||||
@app.get("/admin", include_in_schema=False)
|
|
||||||
async def admin_redirect() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "index.html")
|
|
||||||
|
|
||||||
@app.get("/dashboard", include_in_schema=False)
|
|
||||||
async def serve_dashboard_ui() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "index.html")
|
|
||||||
|
|
||||||
@app.get("/login", include_in_schema=False)
|
|
||||||
async def serve_login_ui() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
|
||||||
|
|
||||||
# Add explicit route for /login/index.txt to redirect to /login
|
|
||||||
@app.get("/login/index.txt", include_in_schema=False)
|
|
||||||
async def redirect_login_index_txt() -> RedirectResponse:
|
|
||||||
return RedirectResponse("/login")
|
|
||||||
|
|
||||||
@app.get("/model", include_in_schema=False)
|
|
||||||
async def serve_models_ui() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
|
||||||
|
|
||||||
# Add explicit route for /model/index.txt to redirect to /model
|
|
||||||
@app.get("/model/index.txt", include_in_schema=False)
|
|
||||||
async def redirect_model_index_txt() -> RedirectResponse:
|
|
||||||
return RedirectResponse("/model")
|
|
||||||
|
|
||||||
@app.get("/providers", include_in_schema=False)
|
|
||||||
async def serve_providers_ui() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
|
||||||
|
|
||||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
|
||||||
@app.get("/providers/index.txt", include_in_schema=False)
|
|
||||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
|
||||||
return RedirectResponse("/providers")
|
|
||||||
|
|
||||||
@app.get("/settings", include_in_schema=False)
|
|
||||||
async def serve_settings_ui() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
|
||||||
|
|
||||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
|
||||||
@app.get("/settings/index.txt", include_in_schema=False)
|
|
||||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
|
||||||
return RedirectResponse("/settings")
|
|
||||||
|
|
||||||
@app.get("/transactions", include_in_schema=False)
|
|
||||||
async def serve_transactions_ui() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
|
||||||
|
|
||||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
|
||||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
|
||||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
|
||||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
|
||||||
return RedirectResponse("/unauthorized")
|
|
||||||
|
|
||||||
@app.get("/favicon.ico", include_in_schema=False)
|
|
||||||
async def serve_favicon() -> FileResponse:
|
|
||||||
icon_path = UI_DIST_PATH / "icon.ico"
|
|
||||||
if icon_path.exists():
|
|
||||||
return FileResponse(icon_path)
|
|
||||||
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
|
||||||
|
|
||||||
@app.get("/icon.ico", include_in_schema=False)
|
|
||||||
async def serve_icon() -> FileResponse:
|
|
||||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
|
||||||
|
|
||||||
app.mount(
|
|
||||||
"/static",
|
|
||||||
StaticFiles(directory=UI_DIST_PATH, check_dir=True),
|
|
||||||
name="ui-static",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"Local UI not found at {UI_DIST_PATH} (checked for index.html), falling back to cloud/proxy"
|
|
||||||
)
|
|
||||||
if global_settings.fallback_ui_url:
|
|
||||||
logger.info(f"Proxying UI to {global_settings.fallback_ui_url}")
|
|
||||||
|
|
||||||
async def _proxy_ui_request(request: Request, path: str = "") -> Any:
|
|
||||||
# Clean up the target URL
|
|
||||||
base_url = global_settings.fallback_ui_url.rstrip("/")
|
|
||||||
target_url = f"{base_url}/{path.lstrip('/')}"
|
|
||||||
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop("host", None)
|
|
||||||
headers.pop("content-length", None)
|
|
||||||
|
|
||||||
client = httpx.AsyncClient()
|
|
||||||
try:
|
|
||||||
rp_req = client.build_request("GET", target_url, headers=headers)
|
|
||||||
rp_resp = await client.send(rp_req, stream=True)
|
|
||||||
|
|
||||||
async def stream_response() -> Any:
|
|
||||||
try:
|
|
||||||
async for chunk in rp_resp.aiter_raw():
|
|
||||||
yield chunk
|
|
||||||
finally:
|
|
||||||
await rp_resp.aclose()
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
response_headers = dict(rp_resp.headers)
|
|
||||||
# Filter out hop-by-hop headers and others that shouldn't be proxied directly
|
|
||||||
for key in [
|
|
||||||
"transfer-encoding",
|
|
||||||
"connection",
|
|
||||||
"keep-alive",
|
|
||||||
"host",
|
|
||||||
]:
|
|
||||||
response_headers.pop(key, None)
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
stream_response(),
|
|
||||||
status_code=rp_resp.status_code,
|
|
||||||
headers=response_headers,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await client.aclose()
|
|
||||||
logger.error(f"Failed to proxy UI request to {target_url}: {e}")
|
|
||||||
return {"error": "UI Proxy Error", "details": str(e)}
|
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
|
||||||
async def proxy_root_ui(request: Request) -> Any:
|
|
||||||
return await _proxy_ui_request(request, "")
|
|
||||||
|
|
||||||
@app.get("/_next/{path:path}", include_in_schema=False)
|
|
||||||
async def proxy_next_assets(request: Request, path: str) -> Any:
|
|
||||||
return await _proxy_ui_request(request, f"_next/{path}")
|
|
||||||
|
|
||||||
@app.get("/static/{path:path}", include_in_schema=False)
|
|
||||||
async def proxy_static_assets(request: Request, path: str) -> Any:
|
|
||||||
return await _proxy_ui_request(request, f"static/{path}")
|
|
||||||
|
|
||||||
# Proxy common assets that might be at root
|
|
||||||
@app.get("/favicon.ico", include_in_schema=False)
|
|
||||||
async def proxy_favicon(request: Request) -> Any:
|
|
||||||
return await _proxy_ui_request(request, "favicon.ico")
|
|
||||||
|
|
||||||
@app.get("/icon.ico", include_in_schema=False)
|
|
||||||
async def proxy_icon(request: Request) -> Any:
|
|
||||||
return await _proxy_ui_request(request, "icon.ico")
|
|
||||||
|
|
||||||
# SPA routes
|
|
||||||
for route in [
|
|
||||||
"/admin",
|
|
||||||
"/dashboard",
|
|
||||||
"/login",
|
|
||||||
"/model",
|
|
||||||
"/providers",
|
|
||||||
"/settings",
|
|
||||||
"/transactions",
|
|
||||||
"/balances",
|
|
||||||
"/logs",
|
|
||||||
"/usage",
|
|
||||||
"/unauthorized",
|
|
||||||
]:
|
|
||||||
|
|
||||||
@app.get(route, include_in_schema=False)
|
|
||||||
async def proxy_spa_route(request: Request) -> Any:
|
|
||||||
return await _proxy_ui_request(request, "")
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"UI dist directory not found at {UI_DIST_PATH} and no fallback_ui_url configured"
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
|
||||||
async def root_fallback() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"name": global_settings.name,
|
|
||||||
"description": global_settings.description,
|
|
||||||
"version": version,
|
|
||||||
"status": "running",
|
|
||||||
"ui": "not available",
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from .core.logging import get_logger
|
from .core.logging import get_logger
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
@@ -389,6 +389,9 @@ async def get_providers(
|
|||||||
Return cached providers. If include_json, return provider+health; otherwise provider only.
|
Return cached providers. If include_json, return provider+health; otherwise provider only.
|
||||||
Optional filter by pubkey.
|
Optional filter by pubkey.
|
||||||
"""
|
"""
|
||||||
|
if settings.providers_refresh_interval_seconds == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider discovery is disabled")
|
||||||
|
|
||||||
cache = await get_cache()
|
cache = await get_cache()
|
||||||
if not cache:
|
if not cache:
|
||||||
await refresh_providers_cache(pubkey=pubkey)
|
await refresh_providers_cache(pubkey=pubkey)
|
||||||
|
|||||||
@@ -48,13 +48,6 @@ async def calculate_cost( # todo: can be sync
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
cost_data = MaxCostData(
|
|
||||||
base_msats=max_cost,
|
|
||||||
input_msats=0,
|
|
||||||
output_msats=0,
|
|
||||||
total_msats=max_cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "usage" not in response_data or response_data["usage"] is None:
|
if "usage" not in response_data or response_data["usage"] is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No usage data in response, using base cost only",
|
"No usage data in response, using base cost only",
|
||||||
@@ -63,7 +56,12 @@ async def calculate_cost( # todo: can be sync
|
|||||||
"model": response_data.get("model", "unknown"),
|
"model": response_data.get("model", "unknown"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return cost_data
|
return MaxCostData(
|
||||||
|
base_msats=0,
|
||||||
|
input_msats=0,
|
||||||
|
output_msats=0,
|
||||||
|
total_msats=0,
|
||||||
|
)
|
||||||
|
|
||||||
usage_data = response_data["usage"]
|
usage_data = response_data["usage"]
|
||||||
|
|
||||||
@@ -178,7 +176,12 @@ async def calculate_cost( # todo: can be sync
|
|||||||
"model": response_data.get("model", "unknown"),
|
"model": response_data.get("model", "unknown"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return cost_data
|
return MaxCostData(
|
||||||
|
base_msats=max_cost,
|
||||||
|
input_msats=0,
|
||||||
|
output_msats=0,
|
||||||
|
total_msats=max_cost,
|
||||||
|
)
|
||||||
|
|
||||||
input_tokens = usage_data.get("prompt_tokens", 0)
|
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||||
output_tokens = usage_data.get("completion_tokens", 0)
|
output_tokens = usage_data.get("completion_tokens", 0)
|
||||||
@@ -192,8 +195,16 @@ async def calculate_cost( # todo: can be sync
|
|||||||
)
|
)
|
||||||
|
|
||||||
# added for response api
|
# added for response api
|
||||||
input_tokens = input_tokens if input_tokens != 0 else response_data.get("usage", {}).get("input_tokens", 0)
|
input_tokens = (
|
||||||
output_tokens = output_tokens if output_tokens != 0 else response_data.get("usage", {}).get("output_tokens", 0)
|
input_tokens
|
||||||
|
if input_tokens != 0
|
||||||
|
else response_data.get("usage", {}).get("input_tokens", 0)
|
||||||
|
)
|
||||||
|
output_tokens = (
|
||||||
|
output_tokens
|
||||||
|
if output_tokens != 0
|
||||||
|
else response_data.get("usage", {}).get("output_tokens", 0)
|
||||||
|
)
|
||||||
|
|
||||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ async def raw_send_to_lnurl(
|
|||||||
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
||||||
)
|
)
|
||||||
|
|
||||||
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2))
|
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2)) + 1
|
||||||
estimated_fees_msat = estimated_fees_sat * 1000
|
estimated_fees_msat = estimated_fees_sat * 1000
|
||||||
final_amount = amount_msat - estimated_fees_msat
|
final_amount = amount_msat - estimated_fees_msat
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,11 @@ async def list_models(
|
|||||||
else 1.01,
|
else 1.01,
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
|
if include_disabled
|
||||||
|
or (
|
||||||
|
r.upstream_provider_id in providers_by_id
|
||||||
|
and providers_by_id[r.upstream_provider_id].enabled
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -265,6 +270,8 @@ async def get_model_by_id(
|
|||||||
if not row or not row.enabled:
|
if not row or not row.enabled:
|
||||||
return None
|
return None
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||||
|
if not provider or not provider.enabled:
|
||||||
|
return None
|
||||||
provider_fee = provider.provider_fee if provider else 1.01
|
provider_fee = provider.provider_fee if provider else 1.01
|
||||||
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
||||||
|
|
||||||
|
|||||||
@@ -79,15 +79,29 @@ async def _fetch_btc_usd_price() -> float:
|
|||||||
"""Fetch the lowest BTC/USD price from multiple exchanges."""
|
"""Fetch the lowest BTC/USD price from multiple exchanges."""
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
try:
|
try:
|
||||||
prices = await asyncio.gather(
|
tasks = [
|
||||||
_kraken_btc_usd(client),
|
asyncio.create_task(_kraken_btc_usd(client)),
|
||||||
_coinbase_btc_usd(client),
|
asyncio.create_task(_coinbase_btc_usd(client)),
|
||||||
_binance_btc_usdt(client),
|
asyncio.create_task(_binance_btc_usdt(client)),
|
||||||
)
|
]
|
||||||
valid_prices = [price for price in prices if price is not None]
|
valid_prices: list[float] = []
|
||||||
|
|
||||||
|
for future in asyncio.as_completed(tasks):
|
||||||
|
price = await future
|
||||||
|
if price is not None:
|
||||||
|
valid_prices.append(price)
|
||||||
|
|
||||||
|
if len(valid_prices) >= 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
if not valid_prices:
|
if not valid_prices:
|
||||||
logger.error("No valid BTC prices obtained from any exchange")
|
logger.error("No valid BTC prices obtained from any exchange")
|
||||||
raise ValueError("Unable to fetch BTC price from any exchange")
|
raise ValueError("Unable to fetch BTC price from any exchange")
|
||||||
|
|
||||||
return min(valid_prices)
|
return min(valid_prices)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
+3
-1
@@ -96,6 +96,8 @@ async def refresh_model_maps() -> None:
|
|||||||
disabled_model_ids: set[str] = set()
|
disabled_model_ids: set[str] = set()
|
||||||
|
|
||||||
for provider in provider_rows:
|
for provider in provider_rows:
|
||||||
|
if not provider.enabled:
|
||||||
|
continue
|
||||||
for model in provider.models:
|
for model in provider.models:
|
||||||
if model.enabled:
|
if model.enabled:
|
||||||
overrides_by_id[model.id] = (model, provider.provider_fee)
|
overrides_by_id[model.id] = (model, provider.provider_fee)
|
||||||
@@ -337,7 +339,7 @@ def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> s
|
|||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No model found in Responses API request",
|
"No model found in Responses API request",
|
||||||
extra={"body_keys": list(request_body_dict.keys())}
|
extra={"body_keys": list(request_body_dict.keys())},
|
||||||
)
|
)
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,11 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Handle model in input field (alternative format)
|
# Handle model in input field (alternative format)
|
||||||
if "input" in data and isinstance(data["input"], dict) and "model" in data["input"]:
|
if (
|
||||||
|
"input" in data
|
||||||
|
and isinstance(data["input"], dict)
|
||||||
|
and "model" in data["input"]
|
||||||
|
):
|
||||||
original_model = model_obj.id
|
original_model = model_obj.id
|
||||||
transformed_model = self.transform_model_name(original_model)
|
transformed_model = self.transform_model_name(original_model)
|
||||||
data["input"]["model"] = transformed_model
|
data["input"]["model"] = transformed_model
|
||||||
@@ -443,6 +447,11 @@ class BaseUpstreamProvider:
|
|||||||
async with create_session() as new_session:
|
async with create_session() as new_session:
|
||||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||||
if not fresh_key:
|
if not fresh_key:
|
||||||
|
logger.warning(
|
||||||
|
"Key not found when finalizing streaming payment",
|
||||||
|
extra={"key_hash": key.hashed_key[:8] + "..."},
|
||||||
|
)
|
||||||
|
usage_finalized = True
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
fallback: dict = {
|
fallback: dict = {
|
||||||
@@ -471,6 +480,7 @@ class BaseUpstreamProvider:
|
|||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
usage_finalized = True
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -580,8 +590,10 @@ class BaseUpstreamProvider:
|
|||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await finalize_without_usage()
|
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
if not usage_finalized:
|
||||||
|
await finalize_without_usage()
|
||||||
|
|
||||||
# Remove inaccurate encoding headers from upstream response
|
# Remove inaccurate encoding headers from upstream response
|
||||||
response_headers = dict(response.headers)
|
response_headers = dict(response.headers)
|
||||||
@@ -734,6 +746,11 @@ class BaseUpstreamProvider:
|
|||||||
async with create_session() as new_session:
|
async with create_session() as new_session:
|
||||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||||
if not fresh_key:
|
if not fresh_key:
|
||||||
|
logger.warning(
|
||||||
|
"Key not found when finalizing Responses API streaming payment",
|
||||||
|
extra={"key_hash": key.hashed_key[:8] + "..."},
|
||||||
|
)
|
||||||
|
usage_finalized = True
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
fallback: dict = {
|
fallback: dict = {
|
||||||
@@ -762,6 +779,7 @@ class BaseUpstreamProvider:
|
|||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
usage_finalized = True
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -779,8 +797,13 @@ class BaseUpstreamProvider:
|
|||||||
|
|
||||||
# Track reasoning tokens for Responses API
|
# Track reasoning tokens for Responses API
|
||||||
if usage := obj.get("usage", {}):
|
if usage := obj.get("usage", {}):
|
||||||
if isinstance(usage, dict) and "reasoning_tokens" in usage:
|
if (
|
||||||
reasoning_tokens += usage.get("reasoning_tokens", 0)
|
isinstance(usage, dict)
|
||||||
|
and "reasoning_tokens" in usage
|
||||||
|
):
|
||||||
|
reasoning_tokens += usage.get(
|
||||||
|
"reasoning_tokens", 0
|
||||||
|
)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -881,8 +904,10 @@ class BaseUpstreamProvider:
|
|||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await finalize_without_usage()
|
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
if not usage_finalized:
|
||||||
|
await finalize_without_usage()
|
||||||
|
|
||||||
# Remove inaccurate encoding headers from upstream response
|
# Remove inaccurate encoding headers from upstream response
|
||||||
response_headers = dict(response.headers)
|
response_headers = dict(response.headers)
|
||||||
@@ -933,8 +958,8 @@ class BaseUpstreamProvider:
|
|||||||
"model": response_json.get("model", "unknown"),
|
"model": response_json.get("model", "unknown"),
|
||||||
"has_usage": "usage" in response_json,
|
"has_usage": "usage" in response_json,
|
||||||
"has_reasoning_tokens": "usage" in response_json
|
"has_reasoning_tokens": "usage" in response_json
|
||||||
and isinstance(response_json.get("usage"), dict)
|
and isinstance(response_json.get("usage"), dict)
|
||||||
and "reasoning_tokens" in response_json["usage"],
|
and "reasoning_tokens" in response_json["usage"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1001,6 +1026,44 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _finalize_generic_streaming_payment(
|
||||||
|
self, key_hash: str, max_cost: int, path: str
|
||||||
|
) -> None:
|
||||||
|
"""Background task to finalize payment for generic streaming requests."""
|
||||||
|
async with create_session() as session:
|
||||||
|
key = await session.get(ApiKey, key_hash)
|
||||||
|
if not key:
|
||||||
|
logger.warning(
|
||||||
|
"Key not found during background payment finalization",
|
||||||
|
extra={"key_hash": key_hash[:8] + "..."},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Finalize with "unknown" model and no usage to release reservation/charge max cost
|
||||||
|
await adjust_payment_for_tokens(
|
||||||
|
key,
|
||||||
|
{"model": "unknown", "usage": None},
|
||||||
|
session,
|
||||||
|
max_cost,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Finalized generic streaming payment in background",
|
||||||
|
extra={
|
||||||
|
"path": path,
|
||||||
|
"key_hash": key_hash[:8] + "...",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error finalizing generic streaming payment in background",
|
||||||
|
extra={
|
||||||
|
"error": str(e),
|
||||||
|
"key_hash": key_hash[:8] + "...",
|
||||||
|
"path": path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def forward_request(
|
async def forward_request(
|
||||||
self,
|
self,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1152,6 +1215,12 @@ class BaseUpstreamProvider:
|
|||||||
background_tasks = BackgroundTasks()
|
background_tasks = BackgroundTasks()
|
||||||
background_tasks.add_task(response.aclose)
|
background_tasks.add_task(response.aclose)
|
||||||
background_tasks.add_task(client.aclose)
|
background_tasks.add_task(client.aclose)
|
||||||
|
background_tasks.add_task(
|
||||||
|
self._finalize_generic_streaming_payment,
|
||||||
|
key.hashed_key,
|
||||||
|
max_cost_for_model,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Streaming non-chat response",
|
"Streaming non-chat response",
|
||||||
@@ -1355,9 +1424,15 @@ class BaseUpstreamProvider:
|
|||||||
background_tasks = BackgroundTasks()
|
background_tasks = BackgroundTasks()
|
||||||
background_tasks.add_task(response.aclose)
|
background_tasks.add_task(response.aclose)
|
||||||
background_tasks.add_task(client.aclose)
|
background_tasks.add_task(client.aclose)
|
||||||
|
background_tasks.add_task(
|
||||||
|
self._finalize_generic_streaming_payment,
|
||||||
|
key.hashed_key,
|
||||||
|
max_cost_for_model,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Streaming non-chat response",
|
"Streaming non-Responses API response",
|
||||||
extra={
|
extra={
|
||||||
"path": path,
|
"path": path,
|
||||||
"status_code": response.status_code,
|
"status_code": response.status_code,
|
||||||
@@ -2503,7 +2578,10 @@ class BaseUpstreamProvider:
|
|||||||
usage_data = data_json["usage"]
|
usage_data = data_json["usage"]
|
||||||
model = data_json.get("model")
|
model = data_json.get("model")
|
||||||
# Track reasoning tokens for Responses API
|
# Track reasoning tokens for Responses API
|
||||||
if isinstance(usage_data, dict) and "reasoning_tokens" in usage_data:
|
if (
|
||||||
|
isinstance(usage_data, dict)
|
||||||
|
and "reasoning_tokens" in usage_data
|
||||||
|
):
|
||||||
reasoning_tokens = usage_data.get("reasoning_tokens", 0)
|
reasoning_tokens = usage_data.get("reasoning_tokens", 0)
|
||||||
elif "model" in data_json and not model:
|
elif "model" in data_json and not model:
|
||||||
model = data_json["model"]
|
model = data_json["model"]
|
||||||
|
|||||||
@@ -187,11 +187,44 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
|
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
|
||||||
|
payment_finalized = False
|
||||||
|
|
||||||
|
async def finalize_payment() -> None:
|
||||||
|
nonlocal payment_finalized
|
||||||
|
if payment_finalized:
|
||||||
|
return
|
||||||
|
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:
|
||||||
|
await adjust_payment_for_tokens(
|
||||||
|
fresh_key,
|
||||||
|
{
|
||||||
|
"model": model_obj.id,
|
||||||
|
"usage": final_usage_data,
|
||||||
|
},
|
||||||
|
new_session,
|
||||||
|
max_cost_for_model,
|
||||||
|
)
|
||||||
|
payment_finalized = True
|
||||||
|
except Exception as cost_error:
|
||||||
|
logger.error(
|
||||||
|
"Error finalizing Gemini streaming payment in fallback",
|
||||||
|
extra={
|
||||||
|
"error": str(cost_error),
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for chunk in response_generator:
|
async for chunk in response_generator:
|
||||||
sse_data = f"data: {json.dumps(chunk)}\n\n"
|
sse_data = f"data: {json.dumps(chunk)}\n\n"
|
||||||
yield sse_data.encode()
|
yield sse_data.encode()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Error in Gemini streaming response",
|
"Error in Gemini streaming response",
|
||||||
@@ -202,6 +235,9 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
if not payment_finalized:
|
||||||
|
await finalize_payment()
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_with_cost(),
|
stream_with_cost(),
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ async def get_all_models_with_overrides(
|
|||||||
)
|
)
|
||||||
for row in override_rows
|
for row in override_rows
|
||||||
if row.upstream_provider_id is not None
|
if row.upstream_provider_id is not None
|
||||||
|
and row.upstream_provider_id in providers_by_id
|
||||||
|
and providers_by_id[row.upstream_provider_id].enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
all_models: dict[str, Model] = {}
|
all_models: dict[str, Model] = {}
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ async def swap_to_primary_mint(
|
|||||||
amount_msat = token_amount
|
amount_msat = token_amount
|
||||||
else:
|
else:
|
||||||
raise ValueError("Invalid unit")
|
raise ValueError("Invalid unit")
|
||||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2))
|
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
|
||||||
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = input("Enter routstr URL: ")
|
||||||
|
API_KEY = input("Enter key or token: ")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_balance(client: httpx.AsyncClient) -> int:
|
||||||
|
response = await client.get("/v1/balance/info")
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
print(f"Current Balance Info: {data}")
|
||||||
|
return data.get("reserved", 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def reproduce() -> None:
|
||||||
|
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=BASE_URL, headers=headers, timeout=30.0
|
||||||
|
) as client:
|
||||||
|
print("Checking initial balance...")
|
||||||
|
try:
|
||||||
|
initial_reserved = await get_balance(client)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to get balance: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\nStarting streaming request...")
|
||||||
|
try:
|
||||||
|
# Create a separate client for the stream so we can close it independently if needed,
|
||||||
|
# but usually just breaking the loop and exiting the context manager is enough.
|
||||||
|
# However, to be sure we simulate a harsh disconnect, we can just cancel the task or close the client.
|
||||||
|
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "gpt-5-nano",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Write a long poem about the ocean.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stream": True,
|
||||||
|
},
|
||||||
|
) as response:
|
||||||
|
print(f"Stream status: {response.status_code}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
err_bytes = await response.aread()
|
||||||
|
try:
|
||||||
|
err_str = err_bytes.decode()
|
||||||
|
except Exception:
|
||||||
|
err_str = repr(err_bytes)
|
||||||
|
print(f"Error: {err_str}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Stream started. Reading a few chunks...")
|
||||||
|
count = 0
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
print(f"Received chunk: {len(chunk)} bytes")
|
||||||
|
count += 1
|
||||||
|
if count >= 3:
|
||||||
|
print("Simulating client disconnect (breaking stream)...")
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Stream interrupted (expected): {e}")
|
||||||
|
|
||||||
|
# Wait a bit for the server to realize we disconnected (though with asyncio it might be immediate or depend on keepalive)
|
||||||
|
print("\nWaiting for server to process disconnect...")
|
||||||
|
await asyncio.sleep(21)
|
||||||
|
|
||||||
|
print("\nChecking final balance...")
|
||||||
|
try:
|
||||||
|
final_reserved = await get_balance(client)
|
||||||
|
except Exception:
|
||||||
|
# Retry once if connection was closed
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=BASE_URL, headers=headers, timeout=30.0
|
||||||
|
) as new_client:
|
||||||
|
final_reserved = await get_balance(new_client)
|
||||||
|
|
||||||
|
if final_reserved > initial_reserved:
|
||||||
|
print(
|
||||||
|
f"\n[FAIL] Bug reproduced! Reserved balance increased: {initial_reserved} -> {final_reserved}"
|
||||||
|
)
|
||||||
|
print(f"Accumulated reserved balance: {final_reserved - initial_reserved}")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"\n[PASS] Reserved balance released correctly: {initial_reserved} -> {final_reserved}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(reproduce())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
@@ -10,7 +10,6 @@ from httpx import AsyncClient
|
|||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
CashuTokenGenerator,
|
CashuTokenGenerator,
|
||||||
PerformanceValidator,
|
|
||||||
ResponseValidator,
|
ResponseValidator,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,30 +158,7 @@ async def test_error_handling(
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_performance_requirements(integration_client: AsyncClient) -> None:
|
|
||||||
"""Test that endpoints meet performance requirements"""
|
|
||||||
|
|
||||||
validator = PerformanceValidator()
|
|
||||||
|
|
||||||
# Test info endpoint performance
|
|
||||||
for i in range(50):
|
|
||||||
start = validator.start_timing("info_endpoint")
|
|
||||||
response = await integration_client.get("/")
|
|
||||||
validator.end_timing("info_endpoint", start)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Validate 95th percentile is under 500ms
|
|
||||||
result = validator.validate_response_time(
|
|
||||||
"info_endpoint", max_duration=0.5, percentile=0.95
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["valid"], (
|
|
||||||
f"Performance requirement failed: "
|
|
||||||
f"95th percentile was {result['percentile_time']:.3f}s "
|
|
||||||
f"(required < {result['max_allowed']}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import gc
|
|||||||
import statistics
|
import statistics
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
import pytest
|
import pytest
|
||||||
@@ -105,42 +106,46 @@ class TestPerformanceBaseline:
|
|||||||
("GET", "/v1/wallet/info", authenticated_client, None),
|
("GET", "/v1/wallet/info", authenticated_client, None),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Warm up
|
# Enable provider discovery for this test
|
||||||
for _ in range(10):
|
with patch(
|
||||||
await integration_client.get("/")
|
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
|
||||||
|
):
|
||||||
|
# Warm up
|
||||||
|
for _ in range(10):
|
||||||
|
await integration_client.get("/")
|
||||||
|
|
||||||
# Test each endpoint
|
# Test each endpoint
|
||||||
for method, path, client, data in endpoints:
|
for method, path, client, data in endpoints:
|
||||||
response_times = []
|
response_times = []
|
||||||
|
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
response = await client.get(path)
|
response = await client.get(path)
|
||||||
else:
|
else:
|
||||||
response = await client.post(path, json=data)
|
response = await client.post(path, json=data)
|
||||||
|
|
||||||
duration = time.time() - start
|
duration = time.time() - start
|
||||||
response_times.append(duration * 1000) # Convert to ms
|
response_times.append(duration * 1000) # Convert to ms
|
||||||
|
|
||||||
assert response.status_code in [200, 201]
|
assert response.status_code in [200, 201]
|
||||||
|
|
||||||
if i % 10 == 0:
|
if i % 10 == 0:
|
||||||
metrics.record_system_metrics()
|
metrics.record_system_metrics()
|
||||||
|
|
||||||
# Verify 95th percentile < 500ms
|
# Verify 95th percentile < 500ms
|
||||||
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
|
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
|
||||||
assert p95 < 500, (
|
assert p95 < 500, (
|
||||||
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
|
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"\n{method} {path}:")
|
print(f"\n{method} {path}:")
|
||||||
print(f" Mean: {statistics.mean(response_times):.2f}ms")
|
print(f" Mean: {statistics.mean(response_times):.2f}ms")
|
||||||
print(f" P95: {p95:.2f}ms")
|
print(f" P95: {p95:.2f}ms")
|
||||||
print(
|
print(
|
||||||
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
|
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Integration tests for provider management functionality.
|
|||||||
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Generator
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,7 +11,7 @@ from httpx import AsyncClient
|
|||||||
|
|
||||||
from routstr.discovery import _PROVIDERS_CACHE
|
from routstr.discovery import _PROVIDERS_CACHE
|
||||||
|
|
||||||
from .utils import PerformanceValidator, ResponseValidator
|
from .utils import ResponseValidator
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -19,6 +19,15 @@ def _clear_providers_cache() -> None:
|
|||||||
_PROVIDERS_CACHE.clear()
|
_PROVIDERS_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _enable_provider_discovery() -> Generator[None, Any, Any]:
|
||||||
|
"""Enable provider discovery for all tests in this module"""
|
||||||
|
with patch(
|
||||||
|
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_providers_endpoint_default_response(
|
async def test_providers_endpoint_default_response(
|
||||||
@@ -518,46 +527,6 @@ async def test_providers_endpoint_response_format(
|
|||||||
assert isinstance(data_json["providers"], list)
|
assert isinstance(data_json["providers"], list)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_providers_endpoint_performance(integration_client: AsyncClient) -> None:
|
|
||||||
"""Test providers endpoint meets performance requirements"""
|
|
||||||
|
|
||||||
# Mock quick responses to avoid network delays
|
|
||||||
mock_events: list[dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"id": f"event{i}",
|
|
||||||
"content": f"Provider: http://provider{i}.onion",
|
|
||||||
"created_at": 1234567890 + i,
|
|
||||||
}
|
|
||||||
for i in range(5)
|
|
||||||
]
|
|
||||||
|
|
||||||
validator = PerformanceValidator()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
|
||||||
):
|
|
||||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
|
||||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
|
||||||
|
|
||||||
# Test multiple requests
|
|
||||||
for i in range(10):
|
|
||||||
start = validator.start_timing("providers_endpoint")
|
|
||||||
response = await integration_client.get("/v1/providers/")
|
|
||||||
validator.end_timing("providers_endpoint", start)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Validate performance (should be fast with mocked dependencies)
|
|
||||||
perf_result = validator.validate_response_time(
|
|
||||||
"providers_endpoint",
|
|
||||||
max_duration=2.0, # Allow more time since it involves multiple operations
|
|
||||||
percentile=0.95,
|
|
||||||
)
|
|
||||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_providers_endpoint_concurrent_requests(
|
async def test_providers_endpoint_concurrent_requests(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from routstr.core.db import ApiKey
|
|||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
ConcurrencyTester,
|
ConcurrencyTester,
|
||||||
PerformanceValidator,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -551,39 +550,7 @@ async def test_proxy_get_concurrent_requests(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_proxy_get_performance_requirements(
|
|
||||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
"""Test that GET proxy requests meet performance requirements"""
|
|
||||||
|
|
||||||
validator = PerformanceValidator()
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.request") as mock_request:
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.headers = {"content-type": "application/json"}
|
|
||||||
mock_response.json = MagicMock(return_value={"performance": "test"})
|
|
||||||
mock_response.text = '{"performance": "test"}'
|
|
||||||
mock_response.iter_bytes = AsyncMock(return_value=[b'{"performance": "test"}'])
|
|
||||||
mock_request.return_value = mock_response
|
|
||||||
|
|
||||||
# Test multiple requests for performance measurement
|
|
||||||
for i in range(20):
|
|
||||||
start = validator.start_timing("proxy_get")
|
|
||||||
response = await authenticated_client.get(f"/v1/perf-test-{i}")
|
|
||||||
validator.end_timing("proxy_get", start)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Validate performance requirements
|
|
||||||
perf_result = validator.validate_response_time(
|
|
||||||
"proxy_get",
|
|
||||||
max_duration=1.0, # Should complete within 1 second
|
|
||||||
percentile=0.95,
|
|
||||||
)
|
|
||||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
ConcurrencyTester,
|
ConcurrencyTester,
|
||||||
PerformanceValidator,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -290,55 +289,7 @@ async def test_proxy_post_unauthorized_access(integration_client: AsyncClient) -
|
|||||||
assert response.status_code in [400, 401]
|
assert response.status_code in [400, 401]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_proxy_post_performance(
|
|
||||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
"""Test POST endpoint performance requirements"""
|
|
||||||
|
|
||||||
test_payload = {
|
|
||||||
"model": "gpt-3.5-turbo",
|
|
||||||
"messages": [{"role": "user", "content": "Performance test"}],
|
|
||||||
}
|
|
||||||
|
|
||||||
validator = PerformanceValidator()
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.send") as mock_send:
|
|
||||||
# Mock fast responses
|
|
||||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
yield b'{"choices": [{"message": {"content": "Fast"}}], "usage": {"total_tokens": 5}}'
|
|
||||||
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.headers = {"content-type": "application/json"}
|
|
||||||
response_data = {
|
|
||||||
"choices": [{"message": {"content": "Fast"}}],
|
|
||||||
"usage": {"total_tokens": 5},
|
|
||||||
}
|
|
||||||
mock_response.text = json.dumps(response_data)
|
|
||||||
mock_response.json = AsyncMock(return_value=response_data)
|
|
||||||
mock_response.iter_bytes = mock_iter_bytes
|
|
||||||
mock_response.aiter_bytes = mock_iter_bytes
|
|
||||||
mock_send.return_value = mock_response
|
|
||||||
|
|
||||||
# Run multiple requests for performance measurement
|
|
||||||
for i in range(20):
|
|
||||||
start = validator.start_timing("proxy_post")
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/v1/chat/completions", json=test_payload
|
|
||||||
)
|
|
||||||
validator.end_timing("proxy_post", start)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Validate performance
|
|
||||||
perf_result = validator.validate_response_time(
|
|
||||||
"proxy_post",
|
|
||||||
max_duration=1.5, # Allow slightly more time for POST
|
|
||||||
percentile=0.95,
|
|
||||||
)
|
|
||||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Integration tests for wallet information retrieval endpoints.
|
|||||||
Tests GET /v1/wallet/ and GET /v1/wallet/info endpoints with various scenarios.
|
Tests GET /v1/wallet/ and GET /v1/wallet/info endpoints with various scenarios.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -367,30 +367,4 @@ async def test_wallet_info_with_special_characters_in_headers(
|
|||||||
# Note: Current implementation doesn't return refund_address in response
|
# Note: Current implementation doesn't return refund_address in response
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.slow
|
|
||||||
async def test_wallet_endpoints_performance(authenticated_client: AsyncClient) -> None:
|
|
||||||
"""Test wallet endpoints meet performance requirements"""
|
|
||||||
|
|
||||||
# Warm up
|
|
||||||
await authenticated_client.get("/v1/wallet/")
|
|
||||||
|
|
||||||
# Measure response times
|
|
||||||
response_times = []
|
|
||||||
|
|
||||||
for _ in range(50):
|
|
||||||
start_time = time.time()
|
|
||||||
response = await authenticated_client.get("/v1/wallet/")
|
|
||||||
end_time = time.time()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
response_times.append(end_time - start_time)
|
|
||||||
|
|
||||||
# Calculate statistics
|
|
||||||
avg_time = sum(response_times) / len(response_times)
|
|
||||||
max_time = max(response_times)
|
|
||||||
|
|
||||||
# Performance assertions
|
|
||||||
assert avg_time < 0.1 # Average should be under 100ms
|
|
||||||
assert max_time < 0.5 # No request should take more than 500ms
|
|
||||||
|
|||||||
@@ -537,42 +537,4 @@ async def test_refund_with_expired_key(
|
|||||||
assert response.json()["recipient"] == "expired@ln.address"
|
assert response.json()["recipient"] == "expired@ln.address"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.slow
|
|
||||||
async def test_refund_performance(
|
|
||||||
integration_client: AsyncClient, testmint_wallet: Any
|
|
||||||
) -> None:
|
|
||||||
"""Test refund endpoint performance"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
# Create multiple API keys
|
|
||||||
api_keys = []
|
|
||||||
for i in range(10):
|
|
||||||
token = await testmint_wallet.mint_tokens(100 + i)
|
|
||||||
# Use cashu token as Bearer auth to create API key
|
|
||||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
||||||
response = await integration_client.get("/v1/wallet/info")
|
|
||||||
assert response.status_code == 200
|
|
||||||
api_keys.append(response.json()["api_key"])
|
|
||||||
|
|
||||||
# Measure refund times
|
|
||||||
refund_times = []
|
|
||||||
|
|
||||||
for api_key in api_keys:
|
|
||||||
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
response = await integration_client.post("/v1/wallet/refund")
|
|
||||||
end_time = time.time()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
refund_times.append(end_time - start_time)
|
|
||||||
|
|
||||||
# Performance assertions
|
|
||||||
avg_time = sum(refund_times) / len(refund_times)
|
|
||||||
max_time = max(refund_times)
|
|
||||||
|
|
||||||
assert avg_time < 0.5 # Average under 500ms
|
|
||||||
assert max_time < 1.0 # No refund takes more than 1 second
|
|
||||||
|
|||||||
+8
-3
@@ -4,17 +4,22 @@ FROM base AS deps
|
|||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||||
RUN npm i
|
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN npm run build
|
RUN pnpm run build
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -6,12 +6,14 @@ RUN apk add --no-cache libc6-compat
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
RUN npm ci
|
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Build the UI
|
# Build the UI
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build && \
|
RUN pnpm run build && \
|
||||||
echo "UI build completed at $(date)"
|
echo "UI build completed at $(date)"
|
||||||
|
|
||||||
# Use the builder stage as the final stage
|
# Use the builder stage as the final stage
|
||||||
|
|||||||
+450
-2
@@ -21,7 +21,17 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
import { CalendarIcon, Filter, X } from 'lucide-react';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -31,11 +41,17 @@ interface LogFiltersProps {
|
|||||||
selectedLevel: string;
|
selectedLevel: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
searchText: string;
|
searchText: string;
|
||||||
|
selectedStatusCodes: string[];
|
||||||
|
selectedMethods: string[];
|
||||||
|
selectedEndpoints: string[];
|
||||||
limit: number;
|
limit: number;
|
||||||
onDateChange: (date: string) => void;
|
onDateChange: (date: string) => void;
|
||||||
onLevelChange: (level: string) => void;
|
onLevelChange: (level: string) => void;
|
||||||
onRequestIdChange: (requestId: string) => void;
|
onRequestIdChange: (requestId: string) => void;
|
||||||
onSearchTextChange: (searchText: string) => void;
|
onSearchTextChange: (searchText: string) => void;
|
||||||
|
onStatusCodesChange: (statusCodes: string[]) => void;
|
||||||
|
onMethodsChange: (methods: string[]) => void;
|
||||||
|
onEndpointsChange: (endpoints: string[]) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
}
|
}
|
||||||
@@ -43,16 +59,87 @@ interface LogFiltersProps {
|
|||||||
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||||
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
|
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
|
||||||
|
|
||||||
|
const STATUS_CODE_OPTIONS = [
|
||||||
|
'200',
|
||||||
|
'201',
|
||||||
|
'204',
|
||||||
|
'400',
|
||||||
|
'401',
|
||||||
|
'402',
|
||||||
|
'403',
|
||||||
|
'404',
|
||||||
|
'422',
|
||||||
|
'429',
|
||||||
|
'500',
|
||||||
|
'502',
|
||||||
|
'503',
|
||||||
|
'504',
|
||||||
|
];
|
||||||
|
|
||||||
|
const METHOD_OPTIONS = [
|
||||||
|
'GET',
|
||||||
|
'POST',
|
||||||
|
'PUT',
|
||||||
|
'DELETE',
|
||||||
|
'PATCH',
|
||||||
|
'OPTIONS',
|
||||||
|
'HEAD',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ENDPOINT_OPTIONS = [
|
||||||
|
'/chat/completions',
|
||||||
|
'/v1/chat/completions',
|
||||||
|
'/models',
|
||||||
|
'/v1/models',
|
||||||
|
'/responses',
|
||||||
|
'/v1/responses',
|
||||||
|
'v1/embeddings/models',
|
||||||
|
'/embeddings/models',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FilterBadgeProps {
|
||||||
|
value: string;
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant='secondary'
|
||||||
|
className='flex items-center gap-1 px-1 font-normal'
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(value);
|
||||||
|
}}
|
||||||
|
className='hover:bg-muted-foreground/20 rounded-full'
|
||||||
|
>
|
||||||
|
<X className='h-3 w-3' />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LogFilters({
|
export function LogFilters({
|
||||||
selectedDate,
|
selectedDate,
|
||||||
selectedLevel,
|
selectedLevel,
|
||||||
requestId,
|
requestId,
|
||||||
searchText,
|
searchText,
|
||||||
|
selectedStatusCodes,
|
||||||
|
selectedMethods,
|
||||||
|
selectedEndpoints,
|
||||||
limit,
|
limit,
|
||||||
onDateChange,
|
onDateChange,
|
||||||
onLevelChange,
|
onLevelChange,
|
||||||
onRequestIdChange,
|
onRequestIdChange,
|
||||||
onSearchTextChange,
|
onSearchTextChange,
|
||||||
|
onStatusCodesChange,
|
||||||
|
onMethodsChange,
|
||||||
|
onEndpointsChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
}: LogFiltersProps) {
|
}: LogFiltersProps) {
|
||||||
@@ -68,6 +155,10 @@ export function LogFilters({
|
|||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [statusSearch, setStatusSearch] = useState('');
|
||||||
|
const [methodSearch, setMethodSearch] = useState('');
|
||||||
|
const [endpointSearch, setEndpointSearch] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
||||||
setIsCustom(!currentIsPreset);
|
setIsCustom(!currentIsPreset);
|
||||||
@@ -129,6 +220,31 @@ export function LogFilters({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSelection = (
|
||||||
|
current: string[],
|
||||||
|
value: string,
|
||||||
|
onChange: (val: string[]) => void
|
||||||
|
) => {
|
||||||
|
if (current.includes(value)) {
|
||||||
|
onChange(current.filter((v) => v !== value));
|
||||||
|
} else {
|
||||||
|
onChange([...current, value]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
|
||||||
|
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
|
||||||
|
const newSelection = new Set([...selectedStatusCodes]);
|
||||||
|
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
|
||||||
|
|
||||||
|
if (allIncluded) {
|
||||||
|
codes.forEach((c) => newSelection.delete(c));
|
||||||
|
} else {
|
||||||
|
codes.forEach((c) => newSelection.add(c));
|
||||||
|
}
|
||||||
|
onStatusCodesChange(Array.from(newSelection));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='mb-6'>
|
<Card className='mb-6'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -137,7 +253,8 @@ export function LogFilters({
|
|||||||
Filters
|
Filters
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Filter logs by date, level, request ID, text search, and limit
|
Filter logs by date, level, request ID, text search, status code,
|
||||||
|
method, endpoint and limit
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -197,6 +314,337 @@ export function LogFilters({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>Status Codes</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className='w-full justify-start text-left font-normal'
|
||||||
|
>
|
||||||
|
<div className='flex flex-wrap gap-1'>
|
||||||
|
{selectedStatusCodes.length > 0 ? (
|
||||||
|
selectedStatusCodes.map((code) => (
|
||||||
|
<FilterBadge
|
||||||
|
key={code}
|
||||||
|
value={code}
|
||||||
|
onRemove={(val) =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedStatusCodes,
|
||||||
|
val,
|
||||||
|
onStatusCodesChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className='text-muted-foreground'>All codes</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className='w-64 p-0' align='start'>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder='Search or add status code...'
|
||||||
|
value={statusSearch}
|
||||||
|
onValueChange={setStatusSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
{selectedStatusCodes.length > 0 && (
|
||||||
|
<CommandGroup heading='Selected'>
|
||||||
|
{selectedStatusCodes.map((code) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`selected-${code}`}
|
||||||
|
onSelect={() =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedStatusCodes,
|
||||||
|
code,
|
||||||
|
onStatusCodesChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox checked={true} className='mr-2' />
|
||||||
|
{code}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{statusSearch &&
|
||||||
|
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
|
||||||
|
!selectedStatusCodes.includes(statusSearch) && (
|
||||||
|
<CommandGroup heading='Custom'>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
if (/^\d+$/.test(statusSearch)) {
|
||||||
|
toggleSelection(
|
||||||
|
selectedStatusCodes,
|
||||||
|
statusSearch,
|
||||||
|
onStatusCodesChange
|
||||||
|
);
|
||||||
|
setStatusSearch('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className='mr-2 h-4 w-4' />
|
||||||
|
Add "{statusSearch}"
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup heading='Quick Filters'>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => handleQuickStatusCode('4xx')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||||
|
c.startsWith('4')
|
||||||
|
).every((c) => selectedStatusCodes.includes(c))}
|
||||||
|
className='mr-2'
|
||||||
|
/>
|
||||||
|
4xx Errors
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => handleQuickStatusCode('5xx')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={STATUS_CODE_OPTIONS.filter((c) =>
|
||||||
|
c.startsWith('5')
|
||||||
|
).every((c) => selectedStatusCodes.includes(c))}
|
||||||
|
className='mr-2'
|
||||||
|
/>
|
||||||
|
5xx Errors
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading='Common Codes'>
|
||||||
|
{STATUS_CODE_OPTIONS.filter(
|
||||||
|
(code) => !selectedStatusCodes.includes(code)
|
||||||
|
).map((code) => (
|
||||||
|
<CommandItem
|
||||||
|
key={code}
|
||||||
|
onSelect={() =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedStatusCodes,
|
||||||
|
code,
|
||||||
|
onStatusCodesChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox checked={false} className='mr-2' />
|
||||||
|
{code}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>HTTP Methods</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className='w-full justify-start text-left font-normal'
|
||||||
|
>
|
||||||
|
<div className='flex flex-wrap gap-1'>
|
||||||
|
{selectedMethods.length > 0 ? (
|
||||||
|
selectedMethods.map((method) => (
|
||||||
|
<FilterBadge
|
||||||
|
key={method}
|
||||||
|
value={method}
|
||||||
|
onRemove={(val) =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedMethods,
|
||||||
|
val,
|
||||||
|
onMethodsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className='text-muted-foreground'>All methods</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className='w-64 p-0' align='start'>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder='Search or add method...'
|
||||||
|
value={methodSearch}
|
||||||
|
onValueChange={setMethodSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
{selectedMethods.length > 0 && (
|
||||||
|
<CommandGroup heading='Selected'>
|
||||||
|
{selectedMethods.map((method) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`selected-${method}`}
|
||||||
|
onSelect={() =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedMethods,
|
||||||
|
method,
|
||||||
|
onMethodsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox checked={true} className='mr-2' />
|
||||||
|
{method}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{methodSearch &&
|
||||||
|
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
|
||||||
|
!selectedMethods.includes(methodSearch.toUpperCase()) && (
|
||||||
|
<CommandGroup heading='Custom'>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
toggleSelection(
|
||||||
|
selectedMethods,
|
||||||
|
methodSearch.toUpperCase(),
|
||||||
|
onMethodsChange
|
||||||
|
);
|
||||||
|
setMethodSearch('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className='mr-2 h-4 w-4' />
|
||||||
|
Add "{methodSearch.toUpperCase()}"
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{METHOD_OPTIONS.filter(
|
||||||
|
(method) => !selectedMethods.includes(method)
|
||||||
|
).map((method) => (
|
||||||
|
<CommandItem
|
||||||
|
key={method}
|
||||||
|
onSelect={() =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedMethods,
|
||||||
|
method,
|
||||||
|
onMethodsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox checked={false} className='mr-2' />
|
||||||
|
{method}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>Endpoints</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className='w-full justify-start text-left font-normal'
|
||||||
|
>
|
||||||
|
<div className='flex flex-wrap gap-1 overflow-hidden'>
|
||||||
|
{selectedEndpoints.length > 0 ? (
|
||||||
|
selectedEndpoints.map((endpoint) => (
|
||||||
|
<FilterBadge
|
||||||
|
key={endpoint}
|
||||||
|
value={endpoint}
|
||||||
|
onRemove={(val) =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedEndpoints,
|
||||||
|
val,
|
||||||
|
onEndpointsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className='text-muted-foreground'>
|
||||||
|
All endpoints
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className='w-80 p-0' align='start'>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder='Search or add endpoint pattern...'
|
||||||
|
value={endpointSearch}
|
||||||
|
onValueChange={setEndpointSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
{selectedEndpoints.length > 0 && (
|
||||||
|
<CommandGroup heading='Selected'>
|
||||||
|
{selectedEndpoints.map((endpoint) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`selected-${endpoint}`}
|
||||||
|
onSelect={() =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedEndpoints,
|
||||||
|
endpoint,
|
||||||
|
onEndpointsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox checked={true} className='mr-2' />
|
||||||
|
{endpoint}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{endpointSearch &&
|
||||||
|
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
|
||||||
|
!selectedEndpoints.includes(endpointSearch) && (
|
||||||
|
<CommandGroup heading='Custom'>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
toggleSelection(
|
||||||
|
selectedEndpoints,
|
||||||
|
endpointSearch,
|
||||||
|
onEndpointsChange
|
||||||
|
);
|
||||||
|
setEndpointSearch('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className='mr-2 h-4 w-4' />
|
||||||
|
Add "{endpointSearch}"
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup heading='Common Endpoints'>
|
||||||
|
{ENDPOINT_OPTIONS.filter(
|
||||||
|
(endpoint) => !selectedEndpoints.includes(endpoint)
|
||||||
|
).map((endpoint) => (
|
||||||
|
<CommandItem
|
||||||
|
key={endpoint}
|
||||||
|
onSelect={() =>
|
||||||
|
toggleSelection(
|
||||||
|
selectedEndpoints,
|
||||||
|
endpoint,
|
||||||
|
onEndpointsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox checked={false} className='mr-2' />
|
||||||
|
{endpoint}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<Label htmlFor='request-id'>Request ID</Label>
|
<Label htmlFor='request-id'>Request ID</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
+84
-2
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { AppSidebar } from '@/components/app-sidebar';
|
import { AppSidebar } from '@/components/app-sidebar';
|
||||||
import { SiteHeader } from '@/components/site-header';
|
import { SiteHeader } from '@/components/site-header';
|
||||||
@@ -22,15 +22,66 @@ import { LogFilters } from './log-filters';
|
|||||||
import { LogEntryCard } from './log-entry-card';
|
import { LogEntryCard } from './log-entry-card';
|
||||||
import { LogDetailsDialog } from './log-details-dialog';
|
import { LogDetailsDialog } from './log-details-dialog';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'routstr-log-filters';
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
const [selectedDate, setSelectedDate] = useState<string>('all');
|
const [selectedDate, setSelectedDate] = useState<string>('all');
|
||||||
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
||||||
const [requestId, setRequestId] = useState<string>('');
|
const [requestId, setRequestId] = useState<string>('');
|
||||||
const [searchText, setSearchText] = useState<string>('');
|
const [searchText, setSearchText] = useState<string>('');
|
||||||
|
const [selectedStatusCodes, setSelectedStatusCodes] = useState<string[]>([]);
|
||||||
|
const [selectedMethods, setSelectedMethods] = useState<string[]>([]);
|
||||||
|
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>([]);
|
||||||
const [limit, setLimit] = useState<number>(100);
|
const [limit, setLimit] = useState<number>(100);
|
||||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Load filters from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.selectedDate) setSelectedDate(parsed.selectedDate);
|
||||||
|
if (parsed.selectedLevel) setSelectedLevel(parsed.selectedLevel);
|
||||||
|
if (parsed.requestId) setRequestId(parsed.requestId);
|
||||||
|
if (parsed.searchText) setSearchText(parsed.searchText);
|
||||||
|
if (parsed.selectedStatusCodes)
|
||||||
|
setSelectedStatusCodes(parsed.selectedStatusCodes);
|
||||||
|
if (parsed.selectedMethods) setSelectedMethods(parsed.selectedMethods);
|
||||||
|
if (parsed.selectedEndpoints)
|
||||||
|
setSelectedEndpoints(parsed.selectedEndpoints);
|
||||||
|
if (parsed.limit) setLimit(parsed.limit);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load filters from localStorage', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save filters to localStorage whenever they change
|
||||||
|
useEffect(() => {
|
||||||
|
const filters = {
|
||||||
|
selectedDate,
|
||||||
|
selectedLevel,
|
||||||
|
requestId,
|
||||||
|
searchText,
|
||||||
|
selectedStatusCodes,
|
||||||
|
selectedMethods,
|
||||||
|
selectedEndpoints,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
||||||
|
}, [
|
||||||
|
selectedDate,
|
||||||
|
selectedLevel,
|
||||||
|
requestId,
|
||||||
|
searchText,
|
||||||
|
selectedStatusCodes,
|
||||||
|
selectedMethods,
|
||||||
|
selectedEndpoints,
|
||||||
|
limit,
|
||||||
|
]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: logsData,
|
data: logsData,
|
||||||
refetch: refetchLogs,
|
refetch: refetchLogs,
|
||||||
@@ -42,6 +93,9 @@ export default function LogsPage() {
|
|||||||
selectedLevel,
|
selectedLevel,
|
||||||
requestId,
|
requestId,
|
||||||
searchText,
|
searchText,
|
||||||
|
selectedStatusCodes,
|
||||||
|
selectedMethods,
|
||||||
|
selectedEndpoints,
|
||||||
limit,
|
limit,
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -50,6 +104,16 @@ export default function LogsPage() {
|
|||||||
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||||
request_id: requestId || undefined,
|
request_id: requestId || undefined,
|
||||||
search: searchText || undefined,
|
search: searchText || undefined,
|
||||||
|
status_codes:
|
||||||
|
selectedStatusCodes.length > 0
|
||||||
|
? selectedStatusCodes.join(',')
|
||||||
|
: undefined,
|
||||||
|
methods:
|
||||||
|
selectedMethods.length > 0 ? selectedMethods.join(',') : undefined,
|
||||||
|
endpoints:
|
||||||
|
selectedEndpoints.length > 0
|
||||||
|
? selectedEndpoints.join(',')
|
||||||
|
: undefined,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
}),
|
}),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
@@ -60,6 +124,9 @@ export default function LogsPage() {
|
|||||||
setSelectedLevel('all');
|
setSelectedLevel('all');
|
||||||
setRequestId('');
|
setRequestId('');
|
||||||
setSearchText('');
|
setSearchText('');
|
||||||
|
setSelectedStatusCodes([]);
|
||||||
|
setSelectedMethods([]);
|
||||||
|
setSelectedEndpoints([]);
|
||||||
setLimit(100);
|
setLimit(100);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,11 +167,17 @@ export default function LogsPage() {
|
|||||||
selectedLevel={selectedLevel}
|
selectedLevel={selectedLevel}
|
||||||
requestId={requestId}
|
requestId={requestId}
|
||||||
searchText={searchText}
|
searchText={searchText}
|
||||||
|
selectedStatusCodes={selectedStatusCodes}
|
||||||
|
selectedMethods={selectedMethods}
|
||||||
|
selectedEndpoints={selectedEndpoints}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onDateChange={setSelectedDate}
|
onDateChange={setSelectedDate}
|
||||||
onLevelChange={setSelectedLevel}
|
onLevelChange={setSelectedLevel}
|
||||||
onRequestIdChange={setRequestId}
|
onRequestIdChange={setRequestId}
|
||||||
onSearchTextChange={setSearchText}
|
onSearchTextChange={setSearchText}
|
||||||
|
onStatusCodesChange={setSelectedStatusCodes}
|
||||||
|
onMethodsChange={setSelectedMethods}
|
||||||
|
onEndpointsChange={setSelectedEndpoints}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
/>
|
/>
|
||||||
@@ -122,13 +195,22 @@ export default function LogsPage() {
|
|||||||
{(selectedDate !== 'all' ||
|
{(selectedDate !== 'all' ||
|
||||||
selectedLevel !== 'all' ||
|
selectedLevel !== 'all' ||
|
||||||
requestId ||
|
requestId ||
|
||||||
searchText) && (
|
searchText ||
|
||||||
|
selectedStatusCodes.length > 0 ||
|
||||||
|
selectedMethods.length > 0 ||
|
||||||
|
selectedEndpoints.length > 0) && (
|
||||||
<CardDescription className='text-xs sm:text-sm'>
|
<CardDescription className='text-xs sm:text-sm'>
|
||||||
Showing logs
|
Showing logs
|
||||||
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
||||||
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
||||||
{requestId && ` with request ID ${requestId}`}
|
{requestId && ` with request ID ${requestId}`}
|
||||||
{searchText && ` matching "${searchText}"`}
|
{searchText && ` matching "${searchText}"`}
|
||||||
|
{selectedStatusCodes.length > 0 &&
|
||||||
|
` with status ${selectedStatusCodes.join(', ')}`}
|
||||||
|
{selectedMethods.length > 0 &&
|
||||||
|
` with method ${selectedMethods.join(', ')}`}
|
||||||
|
{selectedEndpoints.length > 0 &&
|
||||||
|
` with endpoint ${selectedEndpoints.join(', ')}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface LogsResponse {
|
|||||||
level: string | null;
|
level: string | null;
|
||||||
request_id: string | null;
|
request_id: string | null;
|
||||||
search: string | null;
|
search: string | null;
|
||||||
|
status_codes: string | null;
|
||||||
|
methods: string | null;
|
||||||
|
endpoints: string | null;
|
||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,12 @@ function ProviderBalance({
|
|||||||
return <Skeleton className='h-9 w-24' />;
|
return <Skeleton className='h-9 w-24' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !balanceData?.ok || !balanceData.balance_data) {
|
if (
|
||||||
|
error ||
|
||||||
|
!balanceData?.ok ||
|
||||||
|
balanceData.balance_data === undefined ||
|
||||||
|
balanceData.balance_data === null
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
-9257
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -15,6 +15,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.10",
|
"@radix-ui/react-alert-dialog": "^1.1.10",
|
||||||
"@radix-ui/react-avatar": "^1.1.6",
|
"@radix-ui/react-avatar": "^1.1.6",
|
||||||
@@ -40,17 +41,17 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.6",
|
"@radix-ui/react-toggle": "^1.1.6",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.6",
|
"@radix-ui/react-toggle-group": "^1.1.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.3",
|
"@radix-ui/react-tooltip": "^1.2.3",
|
||||||
"@tanstack/react-query": "^5.74.4",
|
"@tanstack/react-query": "^5.90.16",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.501.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "15.3.1",
|
"next": "15.5.9",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
@@ -67,21 +68,21 @@
|
|||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tanstack/react-query-devtools": "^5.74.4",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-next": "15.3.1",
|
"eslint-config-next": "15.5.9",
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"eslint-plugin-prettier": "^5.2.6",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+544
-544
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -22,6 +22,12 @@
|
|||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user