Compare commits

...
Author SHA1 Message Date
9qeklajc 38f9923469 remove and clean up redundant logs 2026-05-01 23:07:43 +02:00
9qeklajcandGitHub 56d9ff6b3f Merge pull request #482 from Routstr/better-url-request-handling
handle urls correctly
2026-05-01 20:54:48 +02:00
9qeklajc 15b31e7c53 handle urls correctly 2026-05-01 17:08:05 +02:00
9qeklajcandGitHub 52db6fd308 Merge pull request #481 from Routstr/display-running-node-version
display commit if node not aligned with release tag
2026-05-01 16:49:15 +02:00
9qeklajc ceca0e8efc display commit if node not aligned with release tag 2026-05-01 16:47:34 +02:00
9qeklajcandGitHub 89109dc209 Merge pull request #480 from Routstr/clean-up-logging
do not logs redundant infos
2026-05-01 16:29:53 +02:00
9qeklajc 234ea19cad do not logs redundant infos 2026-05-01 16:23:30 +02:00
9qeklajcandGitHub cd7de3958c Merge pull request #478 from Routstr/missing-model-pricing
make sure to always emit sats cost
2026-04-28 00:32:32 +02:00
9qeklajc 8c0ac499ef make sure to always emit sats cost 2026-04-28 00:01:27 +02:00
9qeklajcandGitHub 28d91227af Merge pull request #475 from Routstr/admin-token
Admin token
2026-04-26 22:32:21 +02:00
9qeklajc a2db3e2d57 fmt 2026-04-26 22:19:30 +02:00
9qeklajcandGitHub e43ceb2e43 Merge pull request #477 from Routstr/model-refresh
enforce-models-refresh-from-upstream
2026-04-26 22:03:59 +02:00
9qeklajc 689a07f562 enforce-models-refresh-from-upstream 2026-04-26 21:41:38 +02:00
9qeklajc da487a850e Merge branch 'main' into admin-token 2026-04-26 00:09:00 +02:00
9qeklajcandGitHub fa6d3c76d0 Merge pull request #474 from Routstr/fix-field-label
use correct field label
2026-04-26 00:05:13 +02:00
9qeklajc ede1804d4b use correct field label 2026-04-25 23:57:24 +02:00
9qeklajcandGitHub 3392e8d4cb Merge pull request #473 from Routstr/bump-release-version
release v0.4.3
2026-04-25 11:56:48 +02:00
9qeklajc b5174d9753 release v0.4.3 2026-04-25 11:54:54 +02:00
9qeklajc 1f2ff8a99c added admin token 2026-04-25 11:18:58 +02:00
9qeklajcandGitHub 0c60644ba2 Merge pull request #472 from Routstr/fix-revision
fix migration
2026-04-24 23:08:06 +02:00
9qeklajc aca8d43a61 fix migration 2026-04-24 23:05:09 +02:00
9qeklajcandGitHub 7fe4c1963b Merge pull request #470 from Routstr/add-dev-cut
update migration rev id
2026-04-24 15:21:32 +02:00
9qeklajcandGitHub d69ab913d4 Merge pull request #419 from Routstr/add-dev-cut
add-dev-cut
2026-04-23 23:22:23 +02:00
9qeklajcandGitHub 42b8c332df Merge pull request #467 from Routstr/fix-concurent-refund-with-adding-token-history
Fix concurent refund with adding token history
2026-04-22 23:45:38 +02:00
9qeklajc aa682bf8ec fix test 2026-04-22 23:43:11 +02:00
9qeklajc afd81aeca2 reduce retries 2026-04-22 23:14:45 +02:00
9qeklajc 4e03145323 clean up 2026-04-22 23:13:29 +02:00
9qeklajc fa7d2804bb add test 2026-04-22 23:00:04 +02:00
9qeklajc ccabc5d06d Merge branch 'main' into fix-response-error-forwarding 2026-04-22 22:17:59 +02:00
9qeklajc cb68227c88 fix race cond. test 2026-04-22 22:17:41 +02:00
9qeklajc 1ab7e54bd7 improve history collection 2026-04-22 22:09:13 +02:00
9qeklajc 42dceb0cd6 make table scrollable 2026-04-22 21:57:29 +02:00
9qeklajc 05115c3387 add api-key history and fix race condition while topup 2026-04-22 21:50:22 +02:00
9qeklajc c0c8cafd00 fix forwarding upstream error responses 2026-04-20 16:45:05 +02:00
33 changed files with 2047 additions and 447 deletions
+11
View File
@@ -14,6 +14,14 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Resolve git metadata
id: gitmeta
run: |
echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
echo "tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
@@ -30,6 +38,9 @@ jobs:
with:
context: .
push: true
build-args: |
GIT_COMMIT=${{ steps.gitmeta.outputs.sha }}
GIT_TAG=${{ steps.gitmeta.outputs.tag }}
tags: |
ghcr.io/routstr/proxy:latest
ghcr.io/routstr/core:latest
+4
View File
@@ -21,6 +21,10 @@ WORKDIR /app
COPY . .
ARG GIT_COMMIT=""
ARG GIT_TAG=""
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_TAG=${GIT_TAG}
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
+4
View File
@@ -41,6 +41,10 @@ RUN uv sync --no-dev
# Copy the built UI from the ui-builder stage
COPY --from=ui-builder /app/ui/out ./ui_out
ARG GIT_COMMIT=""
ARG GIT_TAG=""
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_TAG=${GIT_TAG}
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
+34
View File
@@ -0,0 +1,34 @@
"""add cli_tokens table
Revision ID: cli_tokens_001
Revises: e8f9a0b1c2d3
Create Date: 2026-04-25 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "cli_tokens_001"
down_revision = "e8f9a0b1c2d3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"cli_tokens",
sa.Column("id", sa.String(), primary_key=True, nullable=False),
sa.Column("token", sa.String(), nullable=False, unique=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created_at", sa.Integer(), nullable=False),
sa.Column("last_used_at", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.Integer(), nullable=True),
)
op.create_index("ix_cli_tokens_token", "cli_tokens", ["token"], unique=True)
def downgrade() -> None:
op.drop_index("ix_cli_tokens_token", table_name="cli_tokens")
op.drop_table("cli_tokens")
@@ -0,0 +1,46 @@
"""add api key link to cashu_transactions
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-04-20 00:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "d4e5f6a7b8c9"
down_revision = "c3d4e5f6a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")}
if "api_key_hashed_key" not in columns:
op.add_column(
"cashu_transactions",
sa.Column(
"api_key_hashed_key",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
)
if "ix_cashu_transactions_api_key_hashed_key" not in indexes:
op.create_index(
"ix_cashu_transactions_api_key_hashed_key",
"cashu_transactions",
["api_key_hashed_key"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions")
op.drop_column("cashu_transactions", "api_key_hashed_key")
@@ -0,0 +1,20 @@
"""merge heads: routstr_fees + api_key_to_cashu_transactions
Revision ID: e8f9a0b1c2d3
Revises: 02650cd6f028, d4e5f6a7b8c9
Create Date: 2026-04-24 00:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = "e8f9a0b1c2d3"
down_revision = ("02650cd6f028", "d4e5f6a7b8c9")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.4.1"
version = "0.4.3"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
+2 -2
View File
@@ -317,13 +317,13 @@ async def validate_bearer_key(
extra={"key_hash": hashed_key[:8] + "..."},
)
logger.info(
logger.debug(
"AUTH: About to call credit_balance",
extra={"token_preview": bearer_key[:50]},
)
try:
msats = await credit_balance(bearer_key, new_key, session)
logger.info(
logger.debug(
"AUTH: credit_balance returned successfully", extra={"msats": msats}
)
except Exception as credit_error:
+87 -11
View File
@@ -7,7 +7,7 @@ from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel import col, select, update
from .auth import get_billing_key, validate_bearer_key
from .core.db import (
@@ -211,6 +211,26 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
_refund_cache[key] = (expiry, value)
async def _restore_balance(
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int
) -> None:
"""Restore balance after a failed refund mint attempt."""
restore_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == hashed_key)
.values(
balance=col(ApiKey.balance) + balance,
reserved_balance=col(ApiKey.reserved_balance) + reserved_balance,
)
)
await session.exec(restore_stmt) # type: ignore[call-overload]
await session.commit()
logger.info(
"refund_wallet_endpoint: balance restored after mint failure",
extra={"hashed_key": hashed_key, "restored_balance": balance},
)
@router.post("/refund", response_model=None)
async def refund_wallet_endpoint(
authorization: Annotated[str | None, Header()] = None,
@@ -292,7 +312,31 @@ async def refund_wallet_endpoint(
elif remaining_balance <= 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
# Capture values before debit — the session may refresh key after commit
pre_debit_balance = key.balance
pre_debit_reserved = key.reserved_balance
# --- DEBIT FIRST: atomically zero the balance before minting tokens ---
# This prevents the race where a concurrent topup/spend happens between
# reading the balance and minting the refund token (double-spend).
debit_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) == pre_debit_balance)
.where(col(ApiKey.reserved_balance) == pre_debit_reserved)
.values(balance=0, reserved_balance=0)
)
debit_result = await session.exec(debit_stmt) # type: ignore[call-overload]
await session.commit()
if debit_result.rowcount == 0:
# Balance changed between read and debit — another request is active
raise HTTPException(
status_code=409,
detail="Balance changed concurrently. Please retry the refund.",
)
# --- MINT: balance is locked at zero, safe to create the refund token ---
try:
if key.refund_address:
from .core.settings import settings as global_settings
@@ -328,10 +372,12 @@ async def refund_wallet_endpoint(
)
except HTTPException:
# Re-raise HTTP exceptions (like 400 for balance too small)
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
raise
except Exception as e:
# If refund fails, don't modify the database
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
error_msg = str(e)
if (
"mint" in error_msg.lower()
@@ -345,12 +391,6 @@ async def refund_wallet_endpoint(
await _refund_cache_set(bearer_value, result)
previous_reserved_balance = key.reserved_balance
key.balance = 0
key.reserved_balance = 0
session.add(key)
await session.commit()
if "token" in result:
try:
await store_cashu_transaction(
@@ -361,6 +401,7 @@ async def refund_wallet_endpoint(
typ="out",
collected=False,
source="apikey",
api_key_hashed_key=key.hashed_key,
)
except Exception:
pass # store_cashu_transaction already logs
@@ -369,13 +410,48 @@ async def refund_wallet_endpoint(
"refund_wallet_endpoint: refund successful",
extra={
"refunded_msats": remaining_balance_msats,
"previous_reserved_balance": previous_reserved_balance,
"previous_reserved_balance": key.reserved_balance,
},
)
return result
@router.get("/history")
async def wallet_history(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, list[dict[str, str | int | bool | None]]]:
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot view child key history. Please use the parent key instead.",
)
result = await session.exec(
select(CashuTransaction)
.where(CashuTransaction.api_key_hashed_key == key.hashed_key)
.order_by(col(CashuTransaction.created_at).desc())
)
transactions = result.all()
return {
"transactions": [
{
"id": tx.id,
"type": tx.type,
"source": tx.source,
"amount": tx.amount,
"unit": tx.unit,
"mint_url": tx.mint_url,
"created_at": tx.created_at,
"collected": tx.collected,
"swept": tx.swept,
}
for tx in transactions
]
}
@router.post("/donate")
async def donate(token: str, ref: str | None = None) -> str:
try:
+113 -15
View File
@@ -20,6 +20,7 @@ from ..wallet import (
from .db import (
ApiKey,
CashuTransaction,
CliToken,
ModelRow,
UpstreamProviderRow,
create_session,
@@ -38,12 +39,27 @@ ADMIN_SESSION_DURATION = 3600
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
def require_admin_api(request: Request) -> None:
async def require_admin_api(request: Request) -> None:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
expiry = admin_sessions.get(token)
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=403, detail="Unauthorized")
token = auth_header.split(" ", 1)[1]
now_ts = int(datetime.now(timezone.utc).timestamp())
# 1) Short-lived session token (in-memory)
expiry = admin_sessions.get(token)
if expiry and expiry > now_ts:
return
# 2) Long-lived CLI token (DB-backed)
async with create_session() as session:
result = await session.exec(select(CliToken).where(CliToken.token == token))
cli_token = result.first()
if cli_token and (cli_token.expires_at is None or cli_token.expires_at > now_ts):
cli_token.last_used_at = now_ts
session.add(cli_token)
await session.commit()
return
raise HTTPException(status_code=403, detail="Unauthorized")
@@ -242,6 +258,73 @@ async def admin_logout(request: Request) -> dict[str, object]:
return {"ok": True}
# ─── CLI Tokens (long-lived bearer tokens for CLI/agent use) ───
class CliTokenCreate(BaseModel):
name: str
expires_in_days: int | None = None
@admin_router.get("/api/cli-tokens", dependencies=[Depends(require_admin_api)])
async def list_cli_tokens() -> list[dict[str, object]]:
async with create_session() as session:
result = await session.exec(select(CliToken))
tokens = result.all()
return [
{
"id": t.id,
"name": t.name,
"token_preview": f"{t.token[:8]}...{t.token[-4:]}",
"created_at": t.created_at,
"last_used_at": t.last_used_at,
"expires_at": t.expires_at,
}
for t in tokens
]
@admin_router.post("/api/cli-tokens", dependencies=[Depends(require_admin_api)])
async def create_cli_token(payload: CliTokenCreate) -> dict[str, object]:
name = (payload.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Name is required")
raw_token = secrets.token_urlsafe(32)
expires_at: int | None = None
if payload.expires_in_days is not None and payload.expires_in_days > 0:
expires_at = int(datetime.now(timezone.utc).timestamp()) + (
payload.expires_in_days * 86400
)
async with create_session() as session:
cli_token = CliToken(token=raw_token, name=name, expires_at=expires_at)
session.add(cli_token)
await session.commit()
await session.refresh(cli_token)
return {
"id": cli_token.id,
"name": cli_token.name,
"token": raw_token, # full token returned only on creation
"created_at": cli_token.created_at,
"expires_at": cli_token.expires_at,
}
@admin_router.delete(
"/api/cli-tokens/{token_id}", dependencies=[Depends(require_admin_api)]
)
async def revoke_cli_token(token_id: str) -> dict[str, object]:
async with create_session() as session:
cli_token = await session.get(CliToken, token_id)
if not cli_token:
raise HTTPException(status_code=404, detail="Token not found")
await session.delete(cli_token)
await session.commit()
return {"ok": True, "deleted_id": token_id}
class WithdrawRequest(BaseModel):
amount: int
mint_url: str | None = None
@@ -1335,41 +1418,56 @@ async def get_transactions_api(
type: str | None = None,
status: str | None = None,
search: str | None = None,
limit: int = 100,
source: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict:
async with create_session() as session:
from sqlmodel import col
from sqlmodel import col, func
stmt = select(CashuTransaction)
base = select(CashuTransaction)
if type:
stmt = stmt.where(CashuTransaction.type == type)
base = base.where(CashuTransaction.type == type)
if source:
if source == "x-cashu":
base = base.where(
(CashuTransaction.source == "x-cashu")
| (CashuTransaction.source == None) # noqa: E711
)
else:
base = base.where(CashuTransaction.source == source)
if status:
if status == "collected":
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
base = base.where(CashuTransaction.collected == True) # noqa: E712
elif status == "swept":
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
base = base.where(CashuTransaction.swept == True) # noqa: E712
elif status == "pending":
stmt = stmt.where(
base = base.where(
CashuTransaction.collected == False, # noqa: E712
CashuTransaction.swept == False, # noqa: E712
)
if search:
search_pattern = f"%{search}%"
stmt = stmt.where(
base = base.where(
(col(CashuTransaction.id).like(search_pattern))
| (col(CashuTransaction.token).like(search_pattern))
| (col(CashuTransaction.request_id).like(search_pattern))
| (col(CashuTransaction.api_key_hashed_key).like(search_pattern))
)
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
count_result = await session.exec(
select(func.count()).select_from(base.subquery())
)
total = count_result.one()
stmt = base.order_by(col(CashuTransaction.created_at).desc()).offset(offset).limit(limit)
results = await session.exec(stmt)
transactions = results.all()
return {
"transactions": [tx.dict() for tx in transactions],
"total": len(transactions),
"total": total,
}
+25 -2
View File
@@ -79,11 +79,10 @@ class ApiKey(SQLModel, table=True): # type: ignore
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")
logger.info("Reset reserved balances on startup")
class ModelRow(SQLModel, table=True): # type: ignore
@@ -159,6 +158,12 @@ class CashuTransaction(SQLModel, table=True): # type: ignore
default="x-cashu",
description="Payment source: x-cashu or apikey",
)
api_key_hashed_key: str | None = Field(
default=None,
foreign_key="api_keys.hashed_key",
index=True,
description="Associated API key hash for wallet history",
)
async def store_cashu_transaction(
@@ -171,6 +176,7 @@ async def store_cashu_transaction(
collected: bool = False,
created_at: int | None = None,
source: str = "x-cashu",
api_key_hashed_key: str | None = None,
) -> None:
try:
async with create_session() as session:
@@ -184,6 +190,7 @@ async def store_cashu_transaction(
collected=collected,
created_at=created_at or int(time.time()),
source=source,
api_key_hashed_key=api_key_hashed_key,
)
session.add(tx)
await session.commit()
@@ -231,6 +238,22 @@ class RoutstrFee(SQLModel, table=True): # type: ignore
last_paid_at: int | None = Field(default=None)
class CliToken(SQLModel, table=True): # type: ignore
"""Long-lived authorization token for CLI/agent use against admin endpoints."""
__tablename__ = "cli_tokens"
id: str = Field(
primary_key=True, default_factory=lambda: uuid.uuid4().hex
)
token: str = Field(unique=True, index=True, description="Bearer token value")
name: str = Field(description="Human-readable label for this token")
created_at: int = Field(default_factory=lambda: int(time.time()))
last_used_at: int | None = Field(default=None)
expires_at: int | None = Field(
default=None, description="Optional expiry unix timestamp; null = never expires"
)
async def accumulate_routstr_fee(session: AsyncSession, amount_msats: int) -> None:
stmt = (
update(RoutstrFee)
+13 -9
View File
@@ -22,16 +22,20 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
# Get status code and detail - works for both FastAPI and Starlette HTTPException
status_code = getattr(exc, "status_code", 500)
detail = getattr(exc, "detail", str(exc))
path = request.url.path
logger.warning(
"HTTP exception",
extra={
"request_id": request_id,
"status_code": status_code,
"detail": detail,
"path": request.url.path,
},
)
# 4xx is client behaviour; the uvicorn access log already records it.
# Only 5xx warrants a server-side warning/error log here.
if status_code >= 500:
logger.error(
f"HTTP {status_code} on {path}: {detail}",
extra={
"request_id": request_id,
"status_code": status_code,
"detail": detail,
"path": path,
},
)
return JSONResponse(
status_code=status_code,
+34 -9
View File
@@ -41,14 +41,23 @@ import logging.config
import logging.handlers
import os
import re
import sys
import tomllib
from datetime import datetime
from pathlib import Path
from typing import Any
from pythonjsonlogger import jsonlogger
from rich.console import Console
from rich.logging import RichHandler
# Only use RichHandler when stdout is a real TTY. In non-TTY contexts
# (docker logs, pipes, CI) Rich pads every line to width and wraps long
# records, producing visually-empty trailing whitespace and split records.
# A plain StreamHandler avoids both problems.
_stdout_is_tty = sys.stdout.isatty()
_console = Console(soft_wrap=True) if _stdout_is_tty else None
# Define custom TRACE level
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
@@ -261,6 +270,26 @@ def setup_logging() -> None:
if console_enabled:
handlers.append("console")
if _stdout_is_tty:
console_handler: dict[str, Any] = {
"()": RichHandler,
"level": log_level,
"show_time": False,
"show_path": False,
"rich_tracebacks": True,
"markup": True,
"console": _console,
"filters": ["request_id_filter", "security_filter"],
}
else:
console_handler = {
"class": "logging.StreamHandler",
"level": log_level,
"formatter": "plain",
"stream": "ext://sys.stdout",
"filters": ["request_id_filter", "security_filter"],
}
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
@@ -270,6 +299,10 @@ def setup_logging() -> None:
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"plain": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"filters": {
"version_filter": {"()": VersionFilter},
@@ -277,15 +310,7 @@ def setup_logging() -> None:
"security_filter": {"()": SecurityFilter},
},
"handlers": {
"console": {
"()": RichHandler,
"level": log_level,
"show_time": False,
"show_path": False,
"rich_tracebacks": True,
"markup": True,
"filters": ["request_id_filter", "security_filter"],
},
"console": console_handler,
"file": {
"()": DailyRotatingFileHandler,
"level": log_level,
+83 -99
View File
@@ -1,5 +1,4 @@
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
@@ -9,6 +8,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException
from starlette.responses import Response as StarletteResponse
from starlette.types import Scope
from ..auth import periodic_key_reset
from ..balance import balance_router, deprecated_wallet_router
@@ -30,16 +31,12 @@ from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .settings import SettingsService
from .settings import settings as global_settings
from .version import __version__
# Initialize logging first
setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.4.1-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.4.1"
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
@@ -103,8 +100,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
# Pass the accessor (not its current value) so the loop sees providers
# added/changed via reinitialize_upstreams() instead of staying pinned
# to the startup snapshot.
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
refresh_upstreams_models_periodically(get_upstreams)
)
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
@@ -194,6 +194,23 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
)
class _ImmutableStaticFiles(StaticFiles):
"""Static files with long Cache-Control for content-hashed Next.js assets.
Files under `/_next/static/` are emitted with content hashes in their
filenames and never mutate, so we serve them with a one-year immutable
cache header so browsers and CDNs stop revalidating on every reload.
"""
async def get_response(self, path: str, scope: Scope) -> StarletteResponse:
response = await super().get_response(path, scope)
if response.status_code == 200:
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable"
)
return response
app = FastAPI(version=__version__, lifespan=lifespan)
@@ -240,7 +257,7 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
app.mount(
"/_next",
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
_ImmutableStaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
name="next-static",
)
@@ -248,100 +265,70 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
async def serve_root_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
# Add explicit route for /index.txt to redirect to /
# Serve the App Router RSC payload for the home page.
@app.get("/index.txt", include_in_schema=False)
async def redirect_index_txt() -> RedirectResponse:
return RedirectResponse("/")
async def serve_root_rsc() -> FileResponse:
return FileResponse(
UI_DIST_PATH / "index.txt", media_type="text/x-component"
)
# Next.js is built with `trailingSlash: true`, so all UI page URLs end
# with a slash (e.g. `/login/`). The proxy router catches `/{path:path}`
# before FastAPI's `redirect_slashes` logic can normalize the URL, so we
# must register both the with-slash and without-slash variants here.
UI_PAGES = (
"dashboard",
"login",
"model",
"providers",
"settings",
"transactions",
"balances",
"logs",
"usage",
"unauthorized",
)
def _register_ui_page(name: str) -> None:
page_dir = UI_DIST_PATH / name
index_html = page_dir / "index.html"
index_txt = page_dir / "index.txt"
async def serve_page() -> FileResponse:
return FileResponse(index_html)
async def serve_page_rsc() -> FileResponse:
return FileResponse(index_txt, media_type="text/x-component")
app.add_api_route(
f"/{name}",
serve_page,
methods=["GET"],
include_in_schema=False,
name=f"serve_{name}_ui",
)
app.add_api_route(
f"/{name}/",
serve_page,
methods=["GET"],
include_in_schema=False,
name=f"serve_{name}_ui_slash",
)
app.add_api_route(
f"/{name}/index.txt",
serve_page_rsc,
methods=["GET"],
include_in_schema=False,
name=f"serve_{name}_rsc",
)
for _page in UI_PAGES:
_register_ui_page(_page)
@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"
@@ -353,9 +340,6 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
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"
+69 -63
View File
@@ -14,8 +14,54 @@ logger = get_logger(__name__)
request_id_context: ContextVar[str | None] = ContextVar("request_id")
# Methods that are never logged: HEAD requests are health probes from
# monitoring/load balancers, OPTIONS are CORS preflights — both are framework
# chatter, not user-meaningful events.
_SKIP_LOG_METHODS: frozenset[str] = frozenset({"HEAD", "OPTIONS"})
# Path prefixes to skip. Includes Next.js static chunks and the admin
# dashboard's internal polling API (/admin/api/*) which the UI hits on a timer
# to refresh balances, logs, providers, etc. — high volume, low diagnostic
# value. Mutating admin actions are recorded separately in the audit log.
_SKIP_LOG_PREFIXES: tuple[str, ...] = (
"/_next/",
"/admin/api/",
)
# Exact paths to skip. RSC payload prefetches (`*/index.txt`) fire automatically
# as the user hovers near `<Link>`s, and `/v1/wallet/info` is polled by the UI.
_SKIP_LOG_EXACT: frozenset[str] = frozenset(
{
"/favicon.ico",
"/icon.ico",
"/v1/wallet/info",
"/index.txt",
"/login/index.txt",
"/model/index.txt",
"/providers/index.txt",
"/settings/index.txt",
"/transactions/index.txt",
"/balances/index.txt",
"/logs/index.txt",
"/usage/index.txt",
"/unauthorized/index.txt",
}
)
def _should_log(method: str, path: str) -> bool:
if method in _SKIP_LOG_METHODS:
return False
if path in _SKIP_LOG_EXACT:
return False
return not any(path.startswith(prefix) for prefix in _SKIP_LOG_PREFIXES)
class LoggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log detailed request and response information."""
"""Middleware to log proxy interactions and page navigation.
Skips logging for static assets and Next.js chunks to avoid noise.
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Generate request ID
@@ -25,56 +71,20 @@ class LoggingMiddleware(BaseHTTPMiddleware):
# Set request ID in context for logging
token = request_id_context.set(request_id)
path = request.url.path
should_log = _should_log(request.method, path)
# Start timing
start_time = time.time()
# Log request details
request_body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
# Only read body for non-streaming requests
if hasattr(request, "_body"):
request_body = await request.body()
except Exception:
pass
# Log incoming request
logger.info(
"Incoming request",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"headers": {
k: v
for k, v in request.headers.items()
if k.lower()
not in [
"authorization",
"x-cashu",
"cookie",
"cf-connecting-ip",
"cf-ipcountry",
"x-forwarded-for",
"x-real-ip",
]
},
"body_size": len(request_body) if request_body else 0,
},
)
# Log at TRACE level for full body (security filter will redact sensitive data)
if request_body and hasattr(logger, "exception"):
logger.exception(
"Request body",
if should_log:
logger.info(
"Incoming request",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"body": request_body.decode("utf-8", errors="ignore")[
:1000
], # Limit size
"path": path,
"query_params": dict(request.query_params),
},
)
@@ -82,36 +92,32 @@ class LoggingMiddleware(BaseHTTPMiddleware):
try:
response = await call_next(request)
# Calculate duration
duration = time.time() - start_time
# Log response
logger.info(
"Request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration * 1000, 2),
},
)
if should_log:
duration = time.time() - start_time
logger.info(
"Request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": path,
"status_code": response.status_code,
"duration_ms": round(duration * 1000, 2),
},
)
if hasattr(response, "headers"):
response.headers["x-routstr-request-id"] = request_id
return response
except Exception as e:
# Calculate duration
# Always log failures, even for skipped paths, so we don't lose errors.
duration = time.time() - start_time
# Log error
logger.error(
"Request failed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"path": path,
"duration_ms": round(duration * 1000, 2),
"error": str(e),
"error_type": type(e).__name__,
+81
View File
@@ -0,0 +1,81 @@
"""Application version resolution.
Priority order:
1. ``VERSION_SUFFIX`` env var (manual override; preserves prior behaviour).
2. Bare base version when HEAD is on the matching release tag (detected via
``GIT_TAG`` env or ``git describe --tags --exact-match HEAD``).
3. ``GIT_COMMIT`` env var (build-time injection) -> ``<base>+g<sha>``.
4. Local ``.git`` lookup (source checkouts) -> ``<base>+g<sha>``.
5. Fallback: bare base version.
The ``+g<sha>`` form is PEP 440 local-version syntax so the result remains a
valid package version.
"""
from __future__ import annotations
import os
import subprocess
from functools import lru_cache
from pathlib import Path
BASE_VERSION = "0.4.3"
_REPO_ROOT = Path(__file__).resolve().parents[2]
_GIT_TIMEOUT_SECONDS = 2.0
def _run_git(*args: str) -> str | None:
try:
result = subprocess.run( # noqa: S603 - fixed argv, no shell
["git", *args],
cwd=_REPO_ROOT,
check=False,
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT_SECONDS,
)
except (FileNotFoundError, subprocess.SubprocessError, OSError):
return None
if result.returncode != 0:
return None
return result.stdout.strip() or None
def _git_short_sha() -> str | None:
sha = os.getenv("GIT_COMMIT", "").strip()
if sha:
return sha[:7]
return _run_git("rev-parse", "--short=7", "HEAD")
def _on_tagged_release() -> bool:
tag = os.getenv("GIT_TAG", "").strip()
if tag:
return tag.lstrip("v") == BASE_VERSION
described = _run_git("describe", "--tags", "--exact-match", "HEAD")
if not described:
return False
return described.lstrip("v") == BASE_VERSION
@lru_cache(maxsize=1)
def get_version() -> str:
suffix = os.getenv("VERSION_SUFFIX")
if suffix is not None:
return f"{BASE_VERSION}-{suffix}"
if _on_tagged_release():
return BASE_VERSION
sha = _git_short_sha()
if not sha:
return BASE_VERSION
return f"{BASE_VERSION}+g{sha}"
__version__ = get_version()
__all__ = ["BASE_VERSION", "__version__", "get_version"]
+1 -1
View File
@@ -26,7 +26,7 @@ logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
from ..core.main import __version__ as imported_version
from ..core.version import __version__ as imported_version
return imported_version
except Exception:
+7 -1
View File
@@ -350,6 +350,9 @@ async def _update_sats_pricing_once() -> None:
from ..proxy import get_upstreams, refresh_model_maps
upstreams = get_upstreams()
if not upstreams:
return
sats_to_usd = sats_usd_price()
updated_count = 0
@@ -363,7 +366,10 @@ async def _update_sats_pricing_once() -> None:
updated_count += len(updated_models)
if updated_count > 0:
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
logger.info(
f"Updated sats pricing for {updated_count} models",
extra={"models_updated": updated_count},
)
await refresh_model_maps()
+177 -107
View File
@@ -604,57 +604,83 @@ class BaseUpstreamProvider:
)
yield prefix + part
# Stream finished, process usage if found
if usage_chunk_data:
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
usage_chunk_data,
session,
max_cost_for_model,
)
remaining_balance_msats = fresh_key.balance
# Merge cost into usage
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
usage_chunk_data["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
usage_chunk_data["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
)
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
cost_data: dict
try:
adjustment_input = (
usage_chunk_data
if usage_chunk_data is not None
else {
"model": last_model_seen or "unknown",
"usage": None,
}
usage_chunk_data["metadata"]["routstr"]["cost"][
"sats_cost"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["metadata"]["routstr"]["cost"][
"remaining_balance_msats"
] = remaining_balance_msats
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception as e:
logger.exception(
"Error during usage finalization",
extra={
"key_hash": key.hashed_key[:8] + "...",
"error": str(e),
},
)
# Fallback: yield original usage chunk if adjustment fails
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
)
cost_data = await adjust_payment_for_tokens(
fresh_key,
adjustment_input,
session,
max_cost_for_model,
)
usage_finalized = True
except Exception as e:
logger.exception(
"Error during usage finalization",
extra={
"key_hash": key.hashed_key[:8] + "...",
"error": str(e),
},
)
if not usage_finalized:
await finalize_db_only()
# Fall back so we still emit a non-zero sats cost downstream.
cost_data = {
"base_msats": 0,
"input_msats": 0,
"output_msats": 0,
"total_msats": 0,
"total_usd": 0.0,
"input_tokens": 0,
"output_tokens": 0,
}
if usage_chunk_data is None:
if not hasattr(self, "_current_stream_id"):
self._current_stream_id = (
f"chatcmpl-{uuid.uuid4()}"
)
usage_chunk_data = {
"id": self._current_stream_id,
"object": "chat.completion.chunk",
"model": last_model_seen or "unknown",
"choices": [],
"usage": {
"prompt_tokens": cost_data.get(
"input_tokens", 0
),
"completion_tokens": cost_data.get(
"output_tokens", 0
),
"total_tokens": cost_data.get(
"input_tokens", 0
)
+ cost_data.get("output_tokens", 0),
},
}
try:
self.inject_cost_metadata(
usage_chunk_data, cost_data, fresh_key
)
except Exception:
logger.exception(
"Failed to inject cost metadata into streaming chunk",
extra={
"key_hash": key.hashed_key[:8] + "...",
},
)
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if done_seen:
yield b"data: [DONE]\n\n"
@@ -926,65 +952,108 @@ class BaseUpstreamProvider:
)
yield prefix + part
# Stream finished, process usage if found
if usage_chunk_data:
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
usage_chunk_data,
session,
max_cost_for_model,
)
remaining_balance_msats = fresh_key.balance
# Merge cost into usage chunk
if (
"response" in usage_chunk_data
and "usage" in usage_chunk_data["response"]
):
usage_chunk_data["response"]["usage"]["cost"] = (
cost_data.get("total_usd", 0.0)
)
usage_chunk_data["response"]["usage"][
"cost_sats"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["response"]["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
elif "usage" in usage_chunk_data:
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
usage_chunk_data["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
usage_chunk_data["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
)
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
# Always emit a cost-bearing data chunk
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
cost_data: dict
try:
adjustment_input = (
usage_chunk_data
if usage_chunk_data is not None
else {
"model": last_model_seen or "unknown",
"usage": None,
}
usage_chunk_data["metadata"]["routstr"]["cost"][
"sats_cost"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["metadata"]["routstr"]["cost"][
"remaining_balance_msats"
] = remaining_balance_msats
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception:
# Fallback: yield original usage chunk if adjustment fails
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
)
cost_data = await adjust_payment_for_tokens(
fresh_key,
adjustment_input,
session,
max_cost_for_model,
)
usage_finalized = True
except Exception as e:
logger.exception(
"Error during Responses API usage finalization",
extra={
"key_hash": key.hashed_key[:8] + "...",
"error": str(e),
},
)
cost_data = {
"base_msats": 0,
"input_msats": 0,
"output_msats": 0,
"total_msats": 0,
"total_usd": 0.0,
"input_tokens": 0,
"output_tokens": 0,
}
if not usage_finalized:
await finalize_db_only()
if usage_chunk_data is None:
usage_chunk_data = {
"type": "response.completed",
"response": {
"model": last_model_seen or "unknown",
"usage": {
"input_tokens": cost_data.get(
"input_tokens", 0
),
"output_tokens": cost_data.get(
"output_tokens", 0
),
"total_tokens": cost_data.get(
"input_tokens", 0
)
+ cost_data.get("output_tokens", 0),
},
},
"usage": {
"input_tokens": cost_data.get(
"input_tokens", 0
),
"output_tokens": cost_data.get(
"output_tokens", 0
),
"total_tokens": cost_data.get(
"input_tokens", 0
)
+ cost_data.get("output_tokens", 0),
},
}
remaining_balance_msats = fresh_key.balance
sats_cost = cost_data.get("total_msats", 0) // 1000
if (
"response" in usage_chunk_data
and isinstance(usage_chunk_data["response"], dict)
and "usage" in usage_chunk_data["response"]
):
usage_chunk_data["response"]["usage"]["cost"] = (
cost_data.get("total_usd", 0.0)
)
usage_chunk_data["response"]["usage"][
"cost_sats"
] = sats_cost
usage_chunk_data["response"]["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
try:
self.inject_cost_metadata(
usage_chunk_data, cost_data, fresh_key
)
except Exception:
logger.exception(
"Failed to inject cost metadata into Responses streaming chunk",
extra={
"key_hash": key.hashed_key[:8] + "...",
},
)
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if done_seen:
yield b"data: [DONE]\n\n"
@@ -1308,7 +1377,8 @@ class BaseUpstreamProvider:
)
usage_finalized = True
yield f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
# Emit the full combined_data as the cost
yield f"event: cost\ndata: {json.dumps(combined_data)}\n\n".encode()
except Exception:
pass
+17 -7
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import os
import re
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from ..core.settings import Settings
@@ -122,12 +122,16 @@ async def get_all_models_with_overrides(
async def refresh_upstreams_models_periodically(
upstreams: list[BaseUpstreamProvider],
upstreams_provider: (
Callable[[], list[BaseUpstreamProvider]] | list[BaseUpstreamProvider]
),
) -> None:
"""Background task to periodically refresh models cache for all providers.
Args:
upstreams: List of upstream provider instances
upstreams_provider: Either a callable returning the live upstream list
(preferred — picks up providers added/changed via reinitialize_upstreams),
or a static list (legacy, will go stale after reinitialize_upstreams).
"""
import asyncio
import random
@@ -139,9 +143,14 @@ async def refresh_upstreams_models_periodically(
logger.info("Provider models refresh disabled (interval <= 0)")
return
def _resolve_upstreams() -> list[BaseUpstreamProvider]:
if callable(upstreams_provider):
return upstreams_provider()
return upstreams_provider
while True:
try:
for upstream in upstreams:
for upstream in _resolve_upstreams():
try:
await upstream.refresh_models_cache()
except Exception as e:
@@ -188,13 +197,14 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
existing_providers = result.all()
if not existing_providers:
logger.info(
"No upstream providers found in database, seeding from settings"
)
await _seed_providers_from_settings(session, settings)
await session.commit()
result = await session.exec(select(UpstreamProviderRow))
existing_providers = result.all()
if existing_providers:
logger.info(
f"Seeded {len(existing_providers)} upstream providers from settings"
)
async def _init_single_provider(
provider_row: UpstreamProviderRow,
+18 -2
View File
@@ -8,6 +8,7 @@ from cashu.wallet.wallet import Wallet
from sqlmodel import col, select, update
from .core import db, get_logger
from .core.db import store_cashu_transaction
from .core.settings import settings
from .payment.lnurl import raw_send_to_lnurl
@@ -279,6 +280,8 @@ async def credit_balance(
try:
amount, unit, mint_url = await recieve_token(cashu_token)
original_amount = amount
original_unit = unit
logger.info(
"credit_balance: Token redeemed successfully",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
@@ -310,7 +313,20 @@ async def credit_balance(
extra={"new_balance": key.balance},
)
logger.info(
try:
await store_cashu_transaction(
token=cashu_token,
amount=original_amount,
unit=original_unit,
mint_url=mint_url,
typ="in",
source="apikey",
api_key_hashed_key=key.hashed_key,
)
except Exception:
pass
logger.debug(
"Cashu token successfully redeemed and stored",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
)
@@ -472,7 +488,7 @@ async def fetch_all_balances(
async def periodic_payout() -> None:
if not settings.receive_ln_address:
logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout")
logger.warning("RECEIVE_LN_ADDRESS is not set, periodic payout disabled")
return
while True:
await asyncio.sleep(60 * 15)
+302
View File
@@ -0,0 +1,302 @@
"""Integration tests for CLI token management (/admin/api/cli-tokens).
Covers:
- GET /admin/api/cli-tokens — list (preview only, no full token)
- POST /admin/api/cli-tokens — create (returns full token once)
- DELETE /admin/api/cli-tokens/{id} — revoke
- Using a CLI token as Bearer auth against admin endpoints
- Expiry enforcement (expired tokens are rejected by require_admin_api)
- last_used_at bump on successful use
- Auth failures: missing token, wrong token, revoked token
"""
from __future__ import annotations
import secrets
import time
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.admin import admin_sessions
from routstr.core.db import AsyncSession, CliToken
# ──────────────────────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture
async def admin_session_token() -> AsyncGenerator[str, None]:
"""Inject a short-lived admin session token into admin_sessions."""
token = secrets.token_urlsafe(24)
admin_sessions[token] = int(time.time()) + 3600
yield token
admin_sessions.pop(token, None)
@pytest_asyncio.fixture
async def admin_client(
integration_client: AsyncClient, admin_session_token: str
) -> AsyncClient:
"""An integration_client pre-authenticated with an admin session token."""
integration_client.headers["Authorization"] = f"Bearer {admin_session_token}"
return integration_client
# ──────────────────────────────────────────────────────────────────────────────
# Creation
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_cli_token_returns_full_token_once(
admin_client: AsyncClient,
) -> None:
"""POST /admin/api/cli-tokens returns the raw token only on creation."""
resp = await admin_client.post(
"/admin/api/cli-tokens",
json={"name": "my-laptop"},
)
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "my-laptop"
assert isinstance(body["id"], str) and body["id"]
assert isinstance(body["token"], str) and len(body["token"]) >= 32
assert body["expires_at"] is None
assert isinstance(body["created_at"], int)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_cli_token_with_expiry(admin_client: AsyncClient) -> None:
"""expires_in_days sets expires_at ~= now + days * 86400."""
before = int(time.time())
resp = await admin_client.post(
"/admin/api/cli-tokens",
json={"name": "ci-runner", "expires_in_days": 7},
)
assert resp.status_code == 200
body = resp.json()
assert body["expires_at"] is not None
delta = body["expires_at"] - before
# Allow 10s jitter around 7 * 86400
assert 7 * 86400 - 10 <= delta <= 7 * 86400 + 10
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_cli_token_rejects_empty_name(
admin_client: AsyncClient,
) -> None:
resp = await admin_client.post(
"/admin/api/cli-tokens", json={"name": " "}
)
assert resp.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_cli_token_requires_admin(
integration_client: AsyncClient,
) -> None:
"""No admin token / no bearer → 403."""
resp = await integration_client.post(
"/admin/api/cli-tokens", json={"name": "no-auth"}
)
assert resp.status_code == 403
# ──────────────────────────────────────────────────────────────────────────────
# Listing
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_list_cli_tokens_returns_preview_not_full_token(
admin_client: AsyncClient,
) -> None:
"""Listing never leaks the raw token."""
create = await admin_client.post(
"/admin/api/cli-tokens", json={"name": "secret-keeper"}
)
assert create.status_code == 200
full_token = create.json()["token"]
resp = await admin_client.get("/admin/api/cli-tokens")
assert resp.status_code == 200
items = resp.json()
assert any(t["name"] == "secret-keeper" for t in items)
for t in items:
# No 'token' field, only 'token_preview'
assert "token" not in t
assert "token_preview" in t
assert full_token not in t["token_preview"]
assert "..." in t["token_preview"]
@pytest.mark.integration
@pytest.mark.asyncio
async def test_list_cli_tokens_requires_admin(
integration_client: AsyncClient,
) -> None:
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 403
# ──────────────────────────────────────────────────────────────────────────────
# Using a CLI token as admin auth
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_cli_token_authorizes_admin_endpoints(
admin_client: AsyncClient,
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""A freshly-created CLI token can be used as Bearer on admin endpoints."""
create = await admin_client.post(
"/admin/api/cli-tokens", json={"name": "cli-auth"}
)
assert create.status_code == 200
cli_token = create.json()["token"]
token_id = create.json()["id"]
# Use a NEW client to isolate the header from admin_session_token
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 200
# last_used_at should be populated after use
row = await integration_session.get(CliToken, token_id)
assert row is not None
assert row.last_used_at is not None
assert row.last_used_at >= row.created_at
@pytest.mark.integration
@pytest.mark.asyncio
async def test_expired_cli_token_is_rejected(
admin_client: AsyncClient,
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""A CLI token with expires_at in the past → 403."""
create = await admin_client.post(
"/admin/api/cli-tokens",
json={"name": "will-expire", "expires_in_days": 1},
)
assert create.status_code == 200
cli_token = create.json()["token"]
token_id = create.json()["id"]
# Force-expire it in the DB
row = await integration_session.get(CliToken, token_id)
assert row is not None
row.expires_at = int(time.time()) - 1
integration_session.add(row)
await integration_session.commit()
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 403
@pytest.mark.integration
@pytest.mark.asyncio
async def test_invalid_bearer_token_is_rejected(
integration_client: AsyncClient,
) -> None:
integration_client.headers["Authorization"] = "Bearer not-a-real-token"
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 403
# ──────────────────────────────────────────────────────────────────────────────
# Revocation
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_revoke_cli_token_removes_auth(
admin_client: AsyncClient,
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""After DELETE, the token no longer authorizes."""
create = await admin_client.post(
"/admin/api/cli-tokens", json={"name": "to-revoke"}
)
token_id = create.json()["id"]
cli_token = create.json()["token"]
revoke = await admin_client.delete(f"/admin/api/cli-tokens/{token_id}")
assert revoke.status_code == 200
assert revoke.json() == {"ok": True, "deleted_id": token_id}
# Row is gone
row = await integration_session.get(CliToken, token_id)
assert row is None
# Can no longer be used for auth
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 403
@pytest.mark.integration
@pytest.mark.asyncio
async def test_revoke_unknown_cli_token_returns_404(
admin_client: AsyncClient,
) -> None:
resp = await admin_client.delete("/admin/api/cli-tokens/does-not-exist")
assert resp.status_code == 404
@pytest.mark.integration
@pytest.mark.asyncio
async def test_revoke_cli_token_requires_admin(
integration_client: AsyncClient,
) -> None:
resp = await integration_client.delete("/admin/api/cli-tokens/anything")
assert resp.status_code == 403
# ──────────────────────────────────────────────────────────────────────────────
# Lifecycle / uniqueness
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_multiple_tokens_are_independent(
admin_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""Creating N tokens yields N unique tokens that all live in DB."""
names = ["dev-a", "dev-b", "dev-c"]
raw_tokens: list[str] = []
ids: list[str] = []
for name in names:
r = await admin_client.post(
"/admin/api/cli-tokens", json={"name": name}
)
assert r.status_code == 200
raw_tokens.append(r.json()["token"])
ids.append(r.json()["id"])
# All unique
assert len(set(raw_tokens)) == len(raw_tokens)
assert len(set(ids)) == len(ids)
# All in DB
result = await integration_session.exec(
select(CliToken).where(CliToken.name.in_(names)) # type: ignore[attr-defined]
)
rows = result.all()
assert {r.name for r in rows} == set(names)
+101 -1
View File
@@ -13,7 +13,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import ApiKey, CashuTransaction
@pytest.mark.integration
@@ -356,6 +356,70 @@ async def test_concurrent_refund_requests(
assert len(successful) + len(failed) == 5
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_rejects_concurrent_topup_on_same_key(
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Test refund returns 409 when a concurrent topup changes the balance first."""
from routstr import balance as balance_module
wallet_response = await authenticated_client.get("/v1/wallet/")
assert wallet_response.status_code == 200
initial_balance = wallet_response.json()["balance"]
topup_amount_sat = 500
topup_token = await testmint_wallet.mint_tokens(topup_amount_sat)
validate_called = asyncio.Event()
allow_refund_to_continue = asyncio.Event()
original_validate_bearer_key = balance_module.validate_bearer_key
delayed_once = False
async def delayed_validate_bearer_key(*args: Any, **kwargs: Any) -> ApiKey:
nonlocal delayed_once
key = await original_validate_bearer_key(*args, **kwargs)
if not delayed_once:
delayed_once = True
validate_called.set()
await allow_refund_to_continue.wait()
return key
async def issue_refund() -> Any:
return await authenticated_client.post("/v1/wallet/refund")
async def issue_topup() -> Any:
await validate_called.wait()
try:
return await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": topup_token}
)
finally:
allow_refund_to_continue.set()
with patch(
"routstr.balance.validate_bearer_key", new=delayed_validate_bearer_key
):
refund_response, topup_response = await asyncio.gather(
issue_refund(), issue_topup()
)
assert topup_response.status_code == 200
assert topup_response.json()["msats"] == topup_amount_sat * 1000
assert refund_response.status_code == 409
assert (
refund_response.json()["detail"]
== "Balance changed concurrently. Please retry the refund."
)
final_balance_response = await authenticated_client.get("/v1/wallet/")
assert final_balance_response.status_code == 200
assert final_balance_response.json()["balance"] == (
initial_balance + topup_amount_sat * 1000
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_during_active_usage(
@@ -394,6 +458,42 @@ async def test_refund_during_active_usage(
assert response.json()["balance"] == 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_history_returns_apikey_transactions(
authenticated_client: AsyncClient,
testmint_wallet: Any,
integration_session: Any,
) -> None:
wallet_response = await authenticated_client.get("/v1/wallet/")
api_key = wallet_response.json()["api_key"]
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
topup_token = await testmint_wallet.mint_tokens(250)
topup_response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": topup_token}
)
assert topup_response.status_code == 200
refund_response = await authenticated_client.post("/v1/wallet/refund")
assert refund_response.status_code == 200
history_response = await authenticated_client.get("/v1/wallet/history")
assert history_response.status_code == 200
transactions = history_response.json()["transactions"]
assert len(transactions) >= 2
assert all("api_key_hashed_key" not in tx for tx in transactions)
assert {tx["type"] for tx in transactions} >= {"in", "out"}
db_result = await integration_session.execute(
select(CashuTransaction).where(
CashuTransaction.api_key_hashed_key == hashed_key
)
)
db_transactions = db_result.scalars().all()
assert len(db_transactions) >= 2
@pytest.mark.integration
@pytest.mark.asyncio
async def test_mint_unavailability_handling(
+11 -1
View File
@@ -11,7 +11,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import ApiKey, CashuTransaction
from .utils import (
CashuTokenGenerator,
@@ -71,6 +71,16 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
assert db_key.balance == new_balance
assert db_key.balance == initial_balance + (topup_amount * 1000)
tx_result = await integration_session.execute(
select(CashuTransaction).where(
CashuTransaction.token == token,
CashuTransaction.type == "in",
)
)
tx = tx_result.scalar_one()
assert tx.api_key_hashed_key == hashed_key
assert tx.source == "apikey"
@pytest.mark.integration
@pytest.mark.asyncio
+107
View File
@@ -6,6 +6,7 @@ from fastapi.responses import JSONResponse
from routstr.balance import refund_wallet_endpoint
from routstr.core.db import ApiKey, CashuTransaction
from routstr.wallet import credit_balance
def _make_cashu_tx(
@@ -29,6 +30,12 @@ def _exec_result(tx: CashuTransaction | None) -> MagicMock:
return result
def _update_result(rowcount: int) -> MagicMock:
result = MagicMock()
result.rowcount = rowcount
return result
@pytest.mark.asyncio
async def test_refund_x_cashu_returns_token() -> None:
x_cashu_token = "cashuAtest_token_value"
@@ -161,6 +168,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
refund_token = "cashuArefund_apikey_token"
session = MagicMock()
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
@@ -186,6 +194,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
assert call_kwargs["source"] == "apikey"
assert call_kwargs["token"] == refund_token
assert call_kwargs["typ"] == "out"
assert call_kwargs["api_key_hashed_key"] == key.hashed_key
@pytest.mark.asyncio
@@ -194,6 +203,7 @@ async def test_apikey_refund_logs_token() -> None:
refund_token = "cashuAlogged_token"
session = MagicMock()
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
@@ -222,6 +232,7 @@ async def test_apikey_refund_log_includes_path() -> None:
refund_token = "cashuApath_token"
session = MagicMock()
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
@@ -248,3 +259,99 @@ async def test_apikey_refund_log_includes_path() -> None:
assert len(token_issued_calls) == 1
extra = token_issued_calls[0].kwargs.get("extra", {})
assert extra.get("path") == "/v1/wallet/refund"
@pytest.mark.asyncio
async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
"""When the debit CAS fails (rowcount=0), no token is minted and 409 is returned."""
from fastapi import HTTPException
key = _make_api_key(balance=5000, refund_currency="sat")
session = MagicMock()
# Debit returns rowcount=0 → balance changed concurrently
session.exec = AsyncMock(return_value=_update_result(0))
session.commit = AsyncMock()
mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted")
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", mock_send_token),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
):
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 409
# Crucially: send_token must NOT have been called
mock_send_token.assert_not_awaited()
@pytest.mark.asyncio
async def test_credit_balance_stores_apikey_transaction_history() -> None:
key = _make_api_key(balance=1000)
session = MagicMock()
session.exec = AsyncMock(return_value=_update_result(1))
session.commit = AsyncMock()
session.refresh = AsyncMock()
with (
patch(
"routstr.wallet.recieve_token",
AsyncMock(return_value=(100, "sat", "https://mint.example")),
),
patch("routstr.wallet.store_cashu_transaction", AsyncMock()) as mock_store,
):
amount = await credit_balance("cashuAtopup_token", key, session)
assert amount == 100_000
mock_store.assert_awaited_once()
call_kwargs = mock_store.call_args.kwargs
assert call_kwargs["typ"] == "in"
assert call_kwargs["source"] == "apikey"
assert call_kwargs["api_key_hashed_key"] == key.hashed_key
assert call_kwargs["amount"] == 100
assert call_kwargs["unit"] == "sat"
assert call_kwargs["token"] == "cashuAtopup_token"
assert call_kwargs["mint_url"] == "https://mint.example"
@pytest.mark.asyncio
async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
"""When debit succeeds but minting fails, balance must be restored."""
from fastapi import HTTPException
key = _make_api_key(balance=5000, refund_currency="sat")
# First exec call = debit (succeeds), second = restore
session = MagicMock()
session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)])
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger"),
):
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 503
# Verify two exec calls: debit + restore
assert session.exec.await_count == 2
+117
View File
@@ -0,0 +1,117 @@
"""Regression tests for the periodic upstream models refresh loop."""
from __future__ import annotations
import asyncio
import os
from typing import cast
from unittest.mock import AsyncMock
import pytest
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
class _FakeUpstream:
"""Minimal stand-in for BaseUpstreamProvider used by the refresh loop.
Only ``base_url`` (for error logging) and ``refresh_models_cache`` (the call
under test) are exercised; everything else stays unused.
"""
def __init__(self, name: str) -> None:
self.base_url = f"http://{name}"
self.refresh_models_cache = AsyncMock()
def _make_fake_upstream(name: str) -> BaseUpstreamProvider:
# The loop only uses duck-typed attributes — cast keeps the test type-clean
# without dragging in BaseUpstreamProvider's full constructor.
return cast(BaseUpstreamProvider, _FakeUpstream(name))
@pytest.mark.asyncio
async def test_refresh_loop_picks_up_providers_added_after_startup(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If a provider is added after the loop starts (e.g. via reinitialize_upstreams),
the next loop iteration must refresh it. Previously the loop captured the upstream
list at startup and missed any later additions."""
from routstr.core.settings import settings as global_settings
from routstr.upstream.helpers import refresh_upstreams_models_periodically
# Tight interval so the test finishes quickly.
monkeypatch.setattr(
global_settings, "models_refresh_interval_seconds", 1, raising=False
)
initial_upstream = _make_fake_upstream("initial")
live_list: list[BaseUpstreamProvider] = [initial_upstream]
# Stub out the post-iteration sats-pricing refresh so the loop body has no DB deps.
async def _noop_pricing_refresh() -> None: # pragma: no cover - trivial stub
return None
monkeypatch.setattr(
"routstr.payment.models._update_sats_pricing_once",
_noop_pricing_refresh,
)
task = asyncio.create_task(
refresh_upstreams_models_periodically(lambda: live_list)
)
try:
# Wait for the first iteration to refresh the initial upstream.
for _ in range(40):
if initial_upstream.refresh_models_cache.await_count >= 1: # type: ignore[attr-defined]
break
await asyncio.sleep(0.05)
assert initial_upstream.refresh_models_cache.await_count >= 1, ( # type: ignore[attr-defined]
"loop did not refresh the initial upstream within the timeout"
)
# Simulate reinitialize_upstreams: replace the live list contents with new
# provider instances. The loop must observe the swap on its next tick.
new_upstream = _make_fake_upstream("added-after-startup")
live_list[:] = [new_upstream]
for _ in range(60):
if new_upstream.refresh_models_cache.await_count >= 1: # type: ignore[attr-defined]
break
await asyncio.sleep(0.05)
assert new_upstream.refresh_models_cache.await_count >= 1, ( # type: ignore[attr-defined]
"loop did not refresh the upstream added after startup — "
"regression: list snapshot captured at startup"
)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_refresh_loop_disabled_when_interval_non_positive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from routstr.core.settings import settings as global_settings
from routstr.upstream.helpers import refresh_upstreams_models_periodically
monkeypatch.setattr(
global_settings, "models_refresh_interval_seconds", 0, raising=False
)
upstream = _make_fake_upstream("never-refreshed")
# Loop must return immediately without ever touching the upstream.
await asyncio.wait_for(
refresh_upstreams_models_periodically(lambda: [upstream]),
timeout=1.0,
)
upstream.refresh_models_cache.assert_not_awaited() # type: ignore[attr-defined]
+9 -1
View File
@@ -51,7 +51,15 @@ async def test_stream_with_id_injection() -> None:
base.adjust_payment_for_tokens = AsyncMock(
return_value={"total_usd": 0.1, "total_msats": 100}
)
base.create_session = MagicMock()
# create_session() is used as an async context manager whose entered
# value exposes an awaitable .get(). Build a mock that behaves that
# way so the post-stream cost-chunk emission can run.
mock_session = MagicMock()
mock_session.get = AsyncMock(return_value=key)
mock_ctx = MagicMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
base.create_session = MagicMock(return_value=mock_ctx)
streaming_response = await provider.handle_streaming_chat_completion(
response=mock_response,
+5
View File
@@ -4,6 +4,7 @@ import * as React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
import { AdminSettings } from '@/components/settings/admin-settings';
import { CliTokensSettings } from '@/components/settings/cli-tokens-settings';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
@@ -19,6 +20,7 @@ export default function SettingsPage() {
<TabsList variant='line' className='mb-4 w-full'>
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
<TabsTrigger value='server'>Server Config</TabsTrigger>
<TabsTrigger value='cli-tokens'>CLI Tokens</TabsTrigger>
</TabsList>
<TabsContent value='server'>
<ServerConfigSettings />
@@ -26,6 +28,9 @@ export default function SettingsPage() {
<TabsContent value='admin'>
<AdminSettings />
</TabsContent>
<TabsContent value='cli-tokens'>
<CliTokensSettings />
</TabsContent>
</Tabs>
</div>
</AppPageShell>
+234 -110
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import {
@@ -31,7 +31,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import {
Empty,
@@ -50,6 +50,8 @@ import {
Receipt,
Key,
Zap,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { AdminService, type Transaction } from '@/lib/api/services/admin';
import { format } from 'date-fns';
@@ -86,85 +88,114 @@ function TransactionTable({
return (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Request ID</TableHead>
<TableHead>Mint</TableHead>
<TableHead>Date</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell>
<div className='flex items-center gap-2'>
{tx.type === 'in' ? (
<ArrowDownLeft className='h-4 w-4 text-green-500' />
) : (
<ArrowUpRight className='h-4 w-4 text-blue-500' />
)}
<span className='capitalize'>{tx.type}</span>
</div>
</TableCell>
<TableCell className='font-mono'>
{tx.amount} {tx.unit}
</TableCell>
<TableCell>{getStatusBadge(tx)}</TableCell>
<TableCell>
{tx.request_id ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[150px] truncate font-mono'>
{tx.request_id}
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() => onCopy(tx.request_id!, tx.id + '-req')}
>
{copiedId === tx.id + '-req' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</TableCell>
<TableCell>
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
<span className='truncate'>{tx.mint_url}</span>
</div>
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(tx.token, tx.id + '-token')}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TableCell>
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Request ID</TableHead>
<TableHead>Mint</TableHead>
<TableHead>Date</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell>
<div className='flex items-center gap-2'>
{tx.type === 'in' ? (
<ArrowDownLeft className='h-4 w-4 text-green-500' />
) : (
<ArrowUpRight className='h-4 w-4 text-blue-500' />
)}
<span className='capitalize'>{tx.type}</span>
</div>
</TableCell>
<TableCell className='font-mono'>
{tx.amount} {tx.unit}
</TableCell>
<TableCell>{getStatusBadge(tx)}</TableCell>
<TableCell>
{tx.api_key_hashed_key ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[120px] truncate font-mono'>
{tx.api_key_hashed_key.slice(0, 12)}...
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() =>
onCopy(tx.api_key_hashed_key!, tx.id + '-apikey')
}
>
{copiedId === tx.id + '-apikey' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</TableCell>
<TableCell>
{tx.request_id ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[150px] truncate font-mono'>
{tx.request_id}
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() => onCopy(tx.request_id!, tx.id + '-req')}
>
{copiedId === tx.id + '-req' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</TableCell>
<TableCell>
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
<span className='truncate'>{tx.mint_url}</span>
</div>
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(tx.token, tx.id + '-token')}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ScrollBar orientation='horizontal' />
</ScrollArea>
);
}
@@ -196,21 +227,63 @@ export default function TransactionsPage() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
}, [search, type, status]);
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['transactions', type, status, search],
const PAGE_SIZE = 50;
const [activeTab, setActiveTab] = useState<string>('x-cashu');
const [xcashuPage, setXcashuPage] = useState(0);
const [apikeyPage, setApikeyPage] = useState(0);
const typeParam = type === 'all' ? undefined : type;
const statusParam = status === 'all' ? undefined : status;
const searchParam = search || undefined;
const xcashuQuery = useQuery({
queryKey: [
'transactions',
'x-cashu',
typeParam,
statusParam,
searchParam,
xcashuPage,
],
queryFn: () =>
AdminService.getTransactions(
type === 'all' ? undefined : type,
status === 'all' ? undefined : status,
search || undefined,
100
typeParam,
statusParam,
searchParam,
'x-cashu',
PAGE_SIZE,
xcashuPage * PAGE_SIZE
),
placeholderData: keepPreviousData,
});
const apikeyQuery = useQuery({
queryKey: [
'transactions',
'apikey',
typeParam,
statusParam,
searchParam,
apikeyPage,
],
queryFn: () =>
AdminService.getTransactions(
typeParam,
statusParam,
searchParam,
'apikey',
PAGE_SIZE,
apikeyPage * PAGE_SIZE
),
placeholderData: keepPreviousData,
});
const handleClearFilters = () => {
setSearch('');
setType('all');
setStatus('all');
setXcashuPage(0);
setApikeyPage(0);
};
const copyToClipboard = (text: string, id: string) => {
@@ -260,14 +333,20 @@ export default function TransactionsPage() {
.filter(Boolean)
.join(' • ');
const xcashuTxs =
data?.transactions.filter((tx) => !tx.source || tx.source === 'x-cashu') ??
[];
const apikeyTxs =
data?.transactions.filter((tx) => tx.source === 'apikey') ?? [];
// Reset pages when filters change
useEffect(() => {
setXcashuPage(0);
setApikeyPage(0);
}, [type, status, search]);
const renderCardContent = (txs: Transaction[]) => {
if (isLoading) {
const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching;
const renderCardContent = (
query: typeof xcashuQuery,
page: number,
setPage: (p: number) => void
) => {
if (query.isLoading) {
return (
<div className='space-y-2'>
{Array.from({ length: 8 }).map((_, index) => (
@@ -279,13 +358,51 @@ export default function TransactionsPage() {
</div>
);
}
const transactions = query.data?.transactions ?? [];
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<TransactionTable
transactions={txs}
copiedId={copiedId}
onCopy={copyToClipboard}
getStatusBadge={getStatusBadge}
/>
<>
{totalPages > 1 && (
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-muted-foreground text-xs sm:text-sm'>
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)}{' '}
of {total}
</span>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
<ChevronLeft className='h-4 w-4' />
<span className='hidden sm:inline'>Previous</span>
</Button>
<span className='text-xs sm:text-sm'>
{page + 1} / {totalPages}
</span>
<Button
variant='outline'
size='sm'
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
>
<span className='hidden sm:inline'>Next</span>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
</div>
)}
<TransactionTable
transactions={transactions}
copiedId={copiedId}
onCopy={copyToClipboard}
getStatusBadge={getStatusBadge}
/>
</>
);
};
@@ -297,7 +414,10 @@ export default function TransactionsPage() {
description='View all incoming and outgoing Cashu token transactions.'
actions={
<Button
onClick={() => refetch()}
onClick={() => {
xcashuQuery.refetch();
apikeyQuery.refetch();
}}
variant='outline'
size='sm'
disabled={isRefetching}
@@ -325,7 +445,7 @@ export default function TransactionsPage() {
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
id='search'
placeholder='Search by ID, token or request ID...'
placeholder='Search by ID, token, request ID or key hash...'
className='pl-8'
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -372,23 +492,27 @@ export default function TransactionsPage() {
</CardContent>
</Card>
<Tabs defaultValue='x-cashu'>
<Tabs
defaultValue='x-cashu'
value={activeTab}
onValueChange={setActiveTab}
>
<TabsList className='mb-4'>
<TabsTrigger value='x-cashu' className='flex items-center gap-2'>
<Zap className='h-4 w-4' />
X-Cashu
{data && (
{xcashuQuery.data && (
<Badge variant='secondary' className='ml-1'>
{xcashuTxs.length}
{xcashuQuery.data.total}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value='apikey' className='flex items-center gap-2'>
<Key className='h-4 w-4' />
API Key Refunds
{data && (
API Key
{apikeyQuery.data && (
<Badge variant='secondary' className='ml-1'>
{apikeyTxs.length}
{apikeyQuery.data.total}
</Badge>
)}
</TabsTrigger>
@@ -407,7 +531,7 @@ export default function TransactionsPage() {
</div>
</CardHeader>
<CardContent className='overflow-hidden'>
{renderCardContent(xcashuTxs)}
{renderCardContent(xcashuQuery, xcashuPage, setXcashuPage)}
</CardContent>
</Card>
</TabsContent>
@@ -416,7 +540,7 @@ export default function TransactionsPage() {
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>API Key Refund History</CardTitle>
<CardTitle>API Key Transaction History</CardTitle>
{hasActiveFilters && (
<CardDescription>
Filtered by {activeFilterDescription}
@@ -425,7 +549,7 @@ export default function TransactionsPage() {
</div>
</CardHeader>
<CardContent className='overflow-hidden'>
{renderCardContent(apikeyTxs)}
{renderCardContent(apikeyQuery, apikeyPage, setApikeyPage)}
</CardContent>
</Card>
</TabsContent>
+3 -3
View File
@@ -540,7 +540,7 @@ export function AddProviderModelDialog({
};
return (
<FormItem>
<FormLabel>Upstream Model ID</FormLabel>
<FormLabel>Client Alias ID</FormLabel>
<FormControl>
<div className='flex gap-2'>
<Input
@@ -565,8 +565,8 @@ export function AddProviderModelDialog({
</div>
</FormControl>
<FormDescription>
Model ID sent to the upstream provider. Defaults to the
model&apos;s own ID.
Alternate ID that clients can use to reference this
model. Defaults to the model&apos;s own ID.
</FormDescription>
<FormMessage />
</FormItem>
@@ -0,0 +1,265 @@
'use client';
import * as React from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
AdminService,
type CliTokenListItem,
type CliTokenCreated,
} from '@/lib/api/services/admin';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Copy, Trash2, Check } from 'lucide-react';
import { toast } from 'sonner';
function formatTs(ts: number | null): string {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString();
}
export function CliTokensSettings(): React.ReactElement {
const [tokens, setTokens] = useState<CliTokenListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState('');
const [expiresInDays, setExpiresInDays] = useState<string>('');
const [creating, setCreating] = useState(false);
const [newToken, setNewToken] = useState<CliTokenCreated | null>(null);
const [copied, setCopied] = useState(false);
const loadTokens = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const data = await AdminService.listCliTokens();
setTokens(data);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Failed to load tokens';
setError(message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadTokens();
}, [loadTokens]);
async function handleCreate(): Promise<void> {
const trimmed = name.trim();
if (!trimmed) {
toast.error('Name is required');
return;
}
const days = expiresInDays.trim()
? Number.parseInt(expiresInDays.trim(), 10)
: undefined;
if (days !== undefined && (Number.isNaN(days) || days <= 0)) {
toast.error('Expiry must be a positive number of days');
return;
}
setCreating(true);
try {
const created = await AdminService.createCliToken(trimmed, days);
setNewToken(created);
setName('');
setExpiresInDays('');
await loadTokens();
toast.success('Token created. Copy it now — it will not be shown again.');
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Failed to create token';
toast.error(message);
} finally {
setCreating(false);
}
}
async function handleRevoke(id: string): Promise<void> {
if (
!confirm('Revoke this token? Any CLI/agent using it will lose access.')
) {
return;
}
try {
await AdminService.revokeCliToken(id);
await loadTokens();
toast.success('Token revoked');
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Failed to revoke token';
toast.error(message);
}
}
async function handleCopy(): Promise<void> {
if (!newToken) return;
await navigator.clipboard.writeText(newToken.token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Create CLI Token</CardTitle>
<CardDescription>
Generate a long-lived bearer token for the Routstr CLI or AI agents.
Use this token in <code>~/.routstr/config.json</code> or with{' '}
<code>routstr init --token &lt;token&gt;</code>.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{newToken && (
<Alert className='border-green-500/50 bg-green-500/10'>
<AlertDescription className='space-y-3'>
<div className='font-medium text-green-700 dark:text-green-400'>
Token created. Copy it now it will not be shown again.
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted flex-1 rounded px-3 py-2 text-xs break-all'>
{newToken.token}
</code>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleCopy}
>
{copied ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setNewToken(null)}
>
Dismiss
</Button>
</AlertDescription>
</Alert>
)}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='cli-token-name'>Name</Label>
<Input
id='cli-token-name'
placeholder='e.g. dev-laptop, ci-runner'
value={name}
onChange={(e) => setName(e.target.value)}
disabled={creating}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='cli-token-expiry'>
Expires in days (optional)
</Label>
<Input
id='cli-token-expiry'
type='number'
min='1'
placeholder='Never expires if blank'
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
disabled={creating}
/>
</div>
</div>
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
{creating ? 'Creating…' : 'Create Token'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Active Tokens</CardTitle>
<CardDescription>
Tokens authorize CLI/agent calls to admin endpoints. Revoke any
token that may have been exposed.
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<Alert variant='destructive' className='mb-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<div className='space-y-2'>
<Skeleton className='h-12 w-full' />
<Skeleton className='h-12 w-full' />
</div>
) : tokens.length === 0 ? (
<p className='text-muted-foreground text-sm'>
No tokens yet. Create one above.
</p>
) : (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead>
<tr className='text-muted-foreground border-b text-left'>
<th className='py-2 pr-4 font-medium'>Name</th>
<th className='py-2 pr-4 font-medium'>Token</th>
<th className='py-2 pr-4 font-medium'>Created</th>
<th className='py-2 pr-4 font-medium'>Last used</th>
<th className='py-2 pr-4 font-medium'>Expires</th>
<th className='py-2 font-medium'></th>
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.id} className='border-b last:border-0'>
<td className='py-2 pr-4'>{t.name}</td>
<td className='py-2 pr-4 font-mono text-xs'>
{t.token_preview}
</td>
<td className='text-muted-foreground py-2 pr-4'>
{formatTs(t.created_at)}
</td>
<td className='text-muted-foreground py-2 pr-4'>
{formatTs(t.last_used_at)}
</td>
<td className='text-muted-foreground py-2 pr-4'>
{t.expires_at ? formatTs(t.expires_at) : 'Never'}
</td>
<td className='py-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => void handleRevoke(t.id)}
>
<Trash2 className='h-4 w-4' />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+45 -1
View File
@@ -891,13 +891,17 @@ export class AdminService {
type?: string,
status?: string,
search?: string,
limit: number = 100
source?: string,
limit: number = 50,
offset: number = 0
): Promise<TransactionsResponse> {
const params = new URLSearchParams();
if (type) params.append('type', type);
if (status) params.append('status', status);
if (search) params.append('search', search);
if (source) params.append('source', source);
params.append('limit', limit.toString());
params.append('offset', offset.toString());
return await apiClient.get<TransactionsResponse>(
`/admin/api/transactions?${params.toString()}`
@@ -962,6 +966,45 @@ export class AdminService {
balance_data: number | null | Record<string, unknown>;
}>(`/admin/api/upstream-providers/${providerId}/balance`);
}
// ── CLI Tokens ──
static async listCliTokens(): Promise<CliTokenListItem[]> {
return await apiClient.get<CliTokenListItem[]>('/admin/api/cli-tokens');
}
static async createCliToken(
name: string,
expiresInDays?: number
): Promise<CliTokenCreated> {
return await apiClient.post<CliTokenCreated>('/admin/api/cli-tokens', {
name,
expires_in_days: expiresInDays ?? null,
});
}
static async revokeCliToken(tokenId: string): Promise<{ ok: boolean }> {
return await apiClient.delete<{ ok: boolean }>(
`/admin/api/cli-tokens/${encodeURIComponent(tokenId)}`
);
}
}
export interface CliTokenListItem {
id: string;
name: string;
token_preview: string;
created_at: number;
last_used_at: number | null;
expires_at: number | null;
}
export interface CliTokenCreated {
id: string;
name: string;
token: string;
created_at: number;
expires_at: number | null;
}
export const TemporaryBalanceSchema = z.object({
@@ -1136,6 +1179,7 @@ export interface Transaction {
collected: boolean;
swept: boolean;
source: 'x-cashu' | 'apikey';
api_key_hashed_key?: string;
}
export interface TransactionsResponse {
Generated
+1 -1
View File
@@ -1878,7 +1878,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.4.1"
version = "0.4.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },