Compare commits

..
14 changed files with 1098 additions and 283 deletions
+22
View File
@@ -3080,6 +3080,9 @@ async def get_logs_api(
level: str | None = None,
request_id: 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,
) -> dict[str, object]:
"""
@@ -3090,16 +3093,32 @@ async def get_logs_api(
level: Filter by log level
request_id: Filter by request ID
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
Returns:
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(
date=date,
level=level,
request_id=request_id,
search_text=search,
status_codes=status_code_list,
methods=method_list,
endpoints=endpoint_list,
limit=limit,
)
@@ -3110,6 +3129,9 @@ async def get_logs_api(
"level": level,
"request_id": request_id,
"search": search,
"status_codes": status_codes,
"methods": methods,
"endpoints": endpoints,
"limit": limit,
}
+43 -1
View File
@@ -105,6 +105,9 @@ class LogManager:
level: str | None = None,
request_id: 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,
) -> list[dict[str, Any]]:
"""
@@ -134,7 +137,13 @@ class LogManager:
for log_data in iterator:
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
@@ -153,6 +162,9 @@ class LogManager:
level: str | None,
request_id: str | None,
search_text_lower: str | None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
@@ -160,6 +172,36 @@ class LogManager:
if request_id and log_data.get("request_id") != request_id:
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:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
+140 -4
View File
@@ -1,11 +1,13 @@
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
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 ..balance import balance_router, deprecated_wallet_router
@@ -25,7 +27,6 @@ from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .settings import SettingsService
from .settings import settings as global_settings
from .ui import setup_ui
# Initialize logging first
setup_logging()
@@ -189,8 +190,143 @@ async def providers() -> RedirectResponse:
return RedirectResponse("/v1/providers/")
# Setup UI routes (local serving or proxy fallback)
setup_ui(app, __version__)
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
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)
+11 -4
View File
@@ -6,7 +6,7 @@ import os
from datetime import datetime, timezone
from typing import Any
from pydantic.v1 import BaseModel, BaseSettings, Field
from pydantic.v1 import BaseModel, BaseSettings, Field, validator
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -37,9 +37,19 @@ class Settings(BaseSettings):
# Cashu
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
@validator("cashu_mints", pre=True, each_item=True)
def normalize_mint_url(cls, v: str) -> str:
if isinstance(v, str):
return v.rstrip("/")
return v
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
disable_testnut_mock_upstream: bool = Field(
default=False, env="DISABLE_TESTNUT_MOCK_UPSTREAM"
)
# Pricing
# Default behavior: derive pricing from MODELS
@@ -89,9 +99,6 @@ class Settings(BaseSettings):
# Discovery
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:
return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin"
-252
View File
@@ -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",
}
+22 -11
View File
@@ -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:
logger.warning(
"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"),
},
)
return cost_data
return MaxCostData(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
)
usage_data = response_data["usage"]
@@ -178,7 +176,12 @@ async def calculate_cost( # todo: can be sync
"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)
output_tokens = usage_data.get("completion_tokens", 0)
@@ -192,8 +195,16 @@ async def calculate_cost( # todo: can be sync
)
# added for response api
input_tokens = input_tokens if input_tokens != 0 else response_data.get("usage", {}).get("input_tokens", 0)
output_tokens = output_tokens if output_tokens != 0 else response_data.get("usage", {}).get("output_tokens", 0)
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
+24 -1
View File
@@ -16,6 +16,7 @@ from .core.db import (
create_session,
get_session,
)
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
check_token_balance,
@@ -25,6 +26,7 @@ from .payment.helpers import (
from .payment.models import Model
from .upstream import BaseUpstreamProvider
from .upstream.helpers import init_upstreams
from .wallet import deserialize_token_from_string
logger = get_logger(__name__)
proxy_router = APIRouter()
@@ -146,6 +148,27 @@ async def proxy(
else:
model_id = request_body_dict.get("model", "unknown")
if (
"https://testnut.cashu.space" in settings.cashu_mints
and not settings.disable_testnut_mock_upstream
):
try:
token_str = None
if x_cashu_header := headers.get("x-cashu"):
token_str = x_cashu_header
elif auth_header := headers.get("authorization"):
parts = auth_header.split(" ")
if len(parts) > 1 and not parts[1].startswith("sk-"):
token_str = parts[1]
if token_str:
token_obj = deserialize_token_from_string(token_str)
if token_obj.mint == "https://testnut.cashu.space":
model_id = "mock/gpt-420-mock"
request_body_dict["model"] = model_id
except Exception:
pass
model_obj = get_model_instance(model_id)
if not model_obj:
return create_error_response(
@@ -337,7 +360,7 @@ def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> s
logger.warning(
"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"
+18 -6
View File
@@ -234,7 +234,11 @@ class BaseUpstreamProvider:
)
# 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
transformed_model = self.transform_model_name(original_model)
data["input"]["model"] = transformed_model
@@ -779,8 +783,13 @@ class BaseUpstreamProvider:
# Track reasoning tokens for Responses API
if usage := obj.get("usage", {}):
if isinstance(usage, dict) and "reasoning_tokens" in usage:
reasoning_tokens += usage.get("reasoning_tokens", 0)
if (
isinstance(usage, dict)
and "reasoning_tokens" in usage
):
reasoning_tokens += usage.get(
"reasoning_tokens", 0
)
except json.JSONDecodeError:
pass
except Exception:
@@ -933,8 +942,8 @@ class BaseUpstreamProvider:
"model": response_json.get("model", "unknown"),
"has_usage": "usage" in response_json,
"has_reasoning_tokens": "usage" in response_json
and isinstance(response_json.get("usage"), dict)
and "reasoning_tokens" in response_json["usage"],
and isinstance(response_json.get("usage"), dict)
and "reasoning_tokens" in response_json["usage"],
},
)
@@ -2503,7 +2512,10 @@ class BaseUpstreamProvider:
usage_data = data_json["usage"]
model = data_json.get("model")
# 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)
elif "model" in data_json and not model:
model = data_json["model"]
+265
View File
@@ -0,0 +1,265 @@
import asyncio
import json
import random
from typing import AsyncIterator
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from ..core.db import ApiKey, AsyncSession
from ..payment.models import Architecture, Model, Pricing
from .base import BaseUpstreamProvider
class MockUpstreamProvider(BaseUpstreamProvider):
"""Fack Mock Upstream provider specifically for Testing."""
provider_type = "mock"
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
if path.endswith("chat/completions"):
is_streaming = False
if request_body:
request_data = json.loads(request_body)
is_streaming = request_data.get("stream", False)
if is_streaming:
async def fake_streaming_response(
chunk_size: int | None = None,
) -> AsyncIterator[bytes]:
suffix = random.randint(1000, 9999)
req_id = f"gen-mock-stream-{suffix}"
created = 1766138895
model = "mock/gpt-420-mock"
def make_chunk(
delta: dict,
finish_reason: str | None = None,
usage: dict | None = None,
) -> bytes:
chunk = {
"id": req_id,
"provider": "MockProvider",
"model": model,
"object": "chat.completion.chunk",
"created": created,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": finish_reason,
"native_finish_reason": "completed"
if finish_reason
else None,
"logprobs": None,
}
],
}
if usage:
chunk["usage"] = usage
return f"data: {json.dumps(chunk)}\n\n".encode()
# 1. Initial chunk
yield make_chunk({"role": "assistant", "content": ""})
await asyncio.sleep(0.02)
# 2. Reasoning chunks
reasoning_tokens = ["Mock", " reason", "ing", "..."]
for token in reasoning_tokens:
delta = {
"role": "assistant",
"content": "",
"reasoning": token,
"reasoning_details": [
{
"type": "reasoning.summary",
"summary": token,
"format": "openai-responses-v1",
"index": 0,
}
],
}
yield make_chunk(delta)
await asyncio.sleep(0.03)
# 3. Content chunks
content_tokens = ["This", " is", " a", " mock", " stream", "."]
for token in content_tokens:
yield make_chunk({"role": "assistant", "content": token})
await asyncio.sleep(0.03)
# 4. Finish chunk
yield make_chunk(
{"role": "assistant", "content": ""}, finish_reason="stop"
)
# 5. Usage chunk
usage_data = {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
"cost": 0.001,
"is_byok": False,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0,
"video_tokens": 0,
},
"cost_details": {
"upstream_inference_cost": None,
"upstream_inference_prompt_cost": 0,
"upstream_inference_completions_cost": 0.001,
},
"completion_tokens_details": {
"reasoning_tokens": 10,
"image_tokens": 0,
},
}
usage_chunk = {
"id": req_id,
"provider": "MockProvider",
"model": model,
"object": "chat.completion.chunk",
"created": created,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": ""},
"finish_reason": None,
"native_finish_reason": None,
"logprobs": None,
}
],
"usage": usage_data,
}
yield f"data: {json.dumps(usage_chunk)}\n\n".encode()
# 6. DONE
yield b"data: [DONE]\n\n"
# 7. Cost
cost_chunk = {
"cost": {
"base_msats": 0,
"input_msats": 2,
"output_msats": 10,
"total_msats": 12,
}
}
yield f"data: {json.dumps(cost_chunk)}\n\n".encode()
return StreamingResponse(
fake_streaming_response(),
200,
)
else:
suffix = random.randint(1000, 9999)
content_dict = {
"id": f"gen-mock-{suffix}",
"provider": "MockProvider",
"model": "mock/gpt-5-mini",
"object": "chat.completion",
"created": 1766138655,
"choices": [
{
"logprobs": None,
"finish_reason": "length",
"native_finish_reason": "max_output_tokens",
"index": 0,
"message": {
"role": "assistant",
"content": f"Mock Content {suffix}",
"refusal": None,
"reasoning": f"Mock Reasoning {suffix}",
"reasoning_details": [
{
"format": "openai-responses-v1",
"index": 0,
"type": "reasoning.summary",
"summary": f"Mock Summary {suffix}",
},
{
"id": f"rs_mock_{suffix}",
"format": "openai-responses-v1",
"index": 0,
"type": "reasoning.encrypted",
"data": "mock_encrypted_data",
},
],
},
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 10,
"total_tokens": 20,
"cost": 0,
"is_byok": False,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0,
"video_tokens": 0,
},
"cost_details": {
"upstream_inference_cost": None,
"upstream_inference_prompt_cost": 0,
"upstream_inference_completions_cost": 0,
},
"completion_tokens_details": {
"reasoning_tokens": 5,
"image_tokens": 0,
},
},
"cost": {
"base_msats": 0,
"input_msats": 0,
"output_msats": 0,
"total_msats": 0,
},
}
return Response(json.dumps(content_dict).encode(), 200)
elif path.endswith("embeddings"):
raise NotImplementedError
elif path.endswith("responses"):
raise NotImplementedError
else:
raise NotImplementedError
async def fetch_models(self) -> list[Model]:
return [
Model(
id="mock/gpt-420-mock",
name="mock/gpt-420-mock",
created=0,
description="mock model for testing",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="",
instruct_type=None,
),
pricing=Pricing(prompt=0.01, completion=0.01),
),
]
def transform_model_name(self, model_id: str) -> str:
return "fake-model"
async def get_balance(self) -> float | None:
return 420.69
+11
View File
@@ -218,6 +218,17 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
results = await asyncio.gather(*tasks)
upstreams = [p for p in results if p is not None]
if (
"https://testnut.cashu.space" in settings.cashu_mints
and not settings.disable_testnut_mock_upstream
):
from .fake import MockUpstreamProvider
mock_provider = MockUpstreamProvider("mock", "mock")
await mock_provider.refresh_models_cache()
upstreams.append(mock_provider)
logger.info("Initialized MockUpstreamProvider for testnut mint")
return upstreams
+5
View File
@@ -313,6 +313,11 @@ async def periodic_payout() -> None:
try:
async with db.create_session() as session:
for mint_url in settings.cashu_mints:
if (
mint_url == "https://testnut.cashu.space"
and not settings.disable_testnut_mock_upstream
):
continue
for unit in ["sat", "msat"]:
wallet = await get_wallet(mint_url, unit)
proofs = get_proofs_per_mint_and_unit(
+450 -2
View File
@@ -21,7 +21,17 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
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 { format } from 'date-fns';
import { cn } from '@/lib/utils';
@@ -31,11 +41,17 @@ interface LogFiltersProps {
selectedLevel: string;
requestId: string;
searchText: string;
selectedStatusCodes: string[];
selectedMethods: string[];
selectedEndpoints: string[];
limit: number;
onDateChange: (date: string) => void;
onLevelChange: (level: string) => void;
onRequestIdChange: (requestId: string) => void;
onSearchTextChange: (searchText: string) => void;
onStatusCodesChange: (statusCodes: string[]) => void;
onMethodsChange: (methods: string[]) => void;
onEndpointsChange: (endpoints: string[]) => void;
onLimitChange: (limit: number) => void;
onClearFilters: () => void;
}
@@ -43,16 +59,87 @@ interface LogFiltersProps {
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
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({
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
onDateChange,
onLevelChange,
onRequestIdChange,
onSearchTextChange,
onStatusCodesChange,
onMethodsChange,
onEndpointsChange,
onLimitChange,
onClearFilters,
}: LogFiltersProps) {
@@ -68,6 +155,10 @@ export function LogFilters({
: undefined
);
const [statusSearch, setStatusSearch] = useState('');
const [methodSearch, setMethodSearch] = useState('');
const [endpointSearch, setEndpointSearch] = useState('');
useEffect(() => {
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
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 (
<Card className='mb-6'>
<CardHeader>
@@ -137,7 +253,8 @@ export function LogFilters({
Filters
</CardTitle>
<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>
</CardHeader>
<CardContent>
@@ -197,6 +314,337 @@ export function LogFilters({
</Select>
</div>
<div className='space-y-2'>
<Label>Status Codes</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedStatusCodes.length > 0 ? (
selectedStatusCodes.map((code) => (
<FilterBadge
key={code}
value={code}
onRemove={(val) =>
toggleSelection(
selectedStatusCodes,
val,
onStatusCodesChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All codes</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add status code...'
value={statusSearch}
onValueChange={setStatusSearch}
/>
<CommandList>
{selectedStatusCodes.length > 0 && (
<CommandGroup heading='Selected'>
{selectedStatusCodes.map((code) => (
<CommandItem
key={`selected-${code}`}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
)}
{statusSearch &&
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
!selectedStatusCodes.includes(statusSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
if (/^\d+$/.test(statusSearch)) {
toggleSelection(
selectedStatusCodes,
statusSearch,
onStatusCodesChange
);
setStatusSearch('');
}
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{statusSearch}&quot;
</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 &quot;{methodSearch.toUpperCase()}&quot;
</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 &quot;{endpointSearch}&quot;
</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'>
<Label htmlFor='request-id'>Request ID</Label>
<Input
+84 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
@@ -22,15 +22,66 @@ import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
import { LogDetailsDialog } from './log-details-dialog';
const STORAGE_KEY = 'routstr-log-filters';
export default function LogsPage() {
const [selectedDate, setSelectedDate] = useState<string>('all');
const [selectedLevel, setSelectedLevel] = useState<string>('all');
const [requestId, setRequestId] = useState<string>('');
const [searchText, setSearchText] = useState<string>('');
const [selectedStatusCodes, setSelectedStatusCodes] = useState<string[]>([]);
const [selectedMethods, setSelectedMethods] = useState<string[]>([]);
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>([]);
const [limit, setLimit] = useState<number>(100);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
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 {
data: logsData,
refetch: refetchLogs,
@@ -42,6 +93,9 @@ export default function LogsPage() {
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
],
queryFn: () =>
@@ -50,6 +104,16 @@ export default function LogsPage() {
level: selectedLevel === 'all' ? undefined : selectedLevel,
request_id: requestId || 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,
}),
refetchInterval: 30000,
@@ -60,6 +124,9 @@ export default function LogsPage() {
setSelectedLevel('all');
setRequestId('');
setSearchText('');
setSelectedStatusCodes([]);
setSelectedMethods([]);
setSelectedEndpoints([]);
setLimit(100);
};
@@ -100,11 +167,17 @@ export default function LogsPage() {
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
@@ -122,13 +195,22 @@ export default function LogsPage() {
{(selectedDate !== 'all' ||
selectedLevel !== 'all' ||
requestId ||
searchText) && (
searchText ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0) && (
<CardDescription className='text-xs sm:text-sm'>
Showing logs
{selectedDate !== 'all' && ` for ${selectedDate}`}
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
{requestId && ` with request ID ${requestId}`}
{searchText && ` matching "${searchText}"`}
{selectedStatusCodes.length > 0 &&
` with status ${selectedStatusCodes.join(', ')}`}
{selectedMethods.length > 0 &&
` with method ${selectedMethods.join(', ')}`}
{selectedEndpoints.length > 0 &&
` with endpoint ${selectedEndpoints.join(', ')}`}
</CardDescription>
)}
</CardHeader>
+3
View File
@@ -17,6 +17,9 @@ export interface LogsResponse {
level: string | null;
request_id: string | null;
search: string | null;
status_codes: string | null;
methods: string | null;
endpoints: string | null;
limit: number;
}