integrate sub2api as upstream for auth/keys/usage via FastAPI BFF

Preserve local user table for superDream-specific features while syncing
user lifecycle, API key CRUD and usage queries through sub2api. Admin token
handles reads and user lifecycle; per-user tokens (Fernet-encrypted in DB)
handle key writes that admin endpoints do not expose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xuyong
2026-04-17 21:23:08 +08:00
parent 20e842a60a
commit 35c0b7de16
30 changed files with 1707 additions and 803 deletions

View File

@@ -3,12 +3,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.datamodels.schemas import (
ForgotPasswordRequest,
LoginRequest,
MessageResponse,
RefreshRequest,
RegisterRequest,
ResetPasswordRequest,
TokenResponse,
UserResponse,
)
from app.integrations.sub2api import admin as sub2api_admin
from app.integrations.sub2api.client import Sub2APIError, Sub2APITransportError
from app.models import User
from app.services.auth_service import AuthService
from app.datamodels.schemas import (
RegisterRequest, LoginRequest, TokenResponse, RefreshRequest,
ForgotPasswordRequest, ResetPasswordRequest, UserResponse, MessageResponse,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -16,7 +24,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
user = await AuthService.register(db, body.email, body.password)
return user
return _user_to_response(user)
@router.post("/login", response_model=TokenResponse)
@@ -38,7 +46,7 @@ async def logout():
async def forgot_password(body: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
token = await AuthService.forgot_password(db, body.email)
if token:
# MVP: print token to console; production: send email
# MVP: print to console; production should send via email
print(f"[Password Reset] email={body.email} token={token}")
return {"message": "If the email exists, a reset link has been sent"}
@@ -51,4 +59,24 @@ async def reset_password(body: ResetPasswordRequest, db: AsyncSession = Depends(
@router.get("/me", response_model=UserResponse)
async def me(user: User = Depends(get_current_user)):
return user
"""Merge the local row with sub2api's live balance when available."""
response = _user_to_response(user)
if user.sub2api_user_id:
try:
remote = await sub2api_admin.get_user(user.sub2api_user_id)
response.balance = float(remote.get("balance") or 0)
except (Sub2APIError, Sub2APITransportError):
# non-fatal; fall back to 0
pass
return response
def _user_to_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
email=user.email,
status=user.status,
created_at=user.created_at,
sub2api_user_id=user.sub2api_user_id,
balance=0.0,
)

View File

@@ -1,39 +1,74 @@
from typing import List
from typing import Any, Dict, List
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.datamodels.schemas import CreateKeyRequest, MessageResponse, UpdateKeyRequest
from app.models import User
from app.services.key_service import KeyService
from app.datamodels.schemas import CreateKeyRequest, ApiKeyResponse, ApiKeyCreatedResponse, MessageResponse
router = APIRouter(prefix="/keys", tags=["keys"])
@router.get("", response_model=List[ApiKeyResponse])
@router.get("")
async def list_keys(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
sort_by: str = Query("created_at"),
sort_order: str = Query("desc"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await KeyService.list_keys(db, user.id)
) -> Dict[str, Any]:
return await KeyService.list_keys(
db, user, page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order
)
@router.post("", response_model=ApiKeyCreatedResponse)
@router.get("/meta/available-groups")
async def available_groups(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> List[Dict[str, Any]]:
return await KeyService.available_groups(db, user)
@router.get("/{key_id}")
async def get_key(
key_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
return await KeyService.get_key(db, user, key_id)
@router.post("")
async def create_key(
body: CreateKeyRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await KeyService.create_key(db, user.id, body.name)
) -> Dict[str, Any]:
payload = body.model_dump(exclude_none=True)
return await KeyService.create_key(db, user, payload)
@router.put("/{key_id}")
async def update_key(
key_id: int,
body: UpdateKeyRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
payload = body.model_dump(exclude_none=True)
return await KeyService.update_key(db, user, key_id, payload)
@router.delete("/{key_id}", response_model=MessageResponse)
async def delete_key(
key_id: str,
key_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await KeyService.delete_key(db, user.id, key_id)
return {"message": "Key deleted"}
await KeyService.delete_key(db, user, key_id)
return {"message": "API key deleted successfully"}

View File

@@ -1,64 +1,112 @@
from datetime import date
from typing import List, Optional
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.datamodels.schemas import DashboardAPIKeysUsageRequest
from app.models import User
from app.services.usage_service import UsageService
from app.datamodels.schemas import (
UsageSummaryResponse, DailyUsageResponse,
ModelUsageResponse, KeyUsageResponse, UsageLogResponse,
)
router = APIRouter(prefix="/usage", tags=["usage"])
@router.get("/summary", response_model=UsageSummaryResponse)
async def usage_summary(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await UsageService.summary(db, user.id)
@router.get("/daily", response_model=List[DailyUsageResponse])
async def usage_daily(
start: Optional[date] = Query(None),
end: Optional[date] = Query(None),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await UsageService.daily(db, user.id, start, end)
@router.get("/by-model", response_model=List[ModelUsageResponse])
async def usage_by_model(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await UsageService.by_model(db, user.id)
@router.get("/by-key", response_model=List[KeyUsageResponse])
async def usage_by_key(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await UsageService.by_key(db, user.id)
@router.get("/logs", response_model=List[UsageLogResponse])
async def usage_logs(
@router.get("")
async def list_logs(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
page_size: int = Query(20, ge=1, le=100),
sort_by: str = Query("created_at"),
sort_order: str = Query("desc"),
api_key_id: Optional[int] = Query(None),
model: Optional[str] = Query(None),
key_id: Optional[str] = Query(None),
start: Optional[date] = Query(None),
end: Optional[date] = Query(None),
request_type: Optional[str] = Query(None),
stream: Optional[bool] = Query(None),
billing_type: Optional[int] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
timezone: Optional[str] = Query(None),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await UsageService.logs(db, user.id, page, size, model, key_id, start, end)
) -> Dict[str, Any]:
return await UsageService.list_logs(
db,
user,
page=page,
page_size=page_size,
sort_by=sort_by,
sort_order=sort_order,
api_key_id=api_key_id,
model=model,
request_type=request_type,
stream=stream,
billing_type=billing_type,
start_date=start_date,
end_date=end_date,
timezone=timezone,
)
@router.get("/stats")
async def stats(
period: Optional[str] = Query(None, regex="^(today|week|month)$"),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
api_key_id: Optional[int] = Query(None),
timezone: Optional[str] = Query(None),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
return await UsageService.stats(
db,
user,
period=period,
start_date=start_date,
end_date=end_date,
api_key_id=api_key_id,
timezone=timezone,
)
@router.get("/dashboard/stats")
async def dashboard_stats(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
return await UsageService.dashboard_stats(db, user)
@router.get("/dashboard/trend")
async def dashboard_trend(
granularity: str = Query("day"),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
timezone: Optional[str] = Query(None),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
return await UsageService.dashboard_trend(
db, user, granularity=granularity, start_date=start_date, end_date=end_date, timezone=timezone
)
@router.get("/dashboard/models")
async def dashboard_models(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
timezone: Optional[str] = Query(None),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
return await UsageService.dashboard_models(
db, user, start_date=start_date, end_date=end_date, timezone=timezone
)
@router.post("/dashboard/api-keys-usage")
async def dashboard_api_keys_usage(
body: DashboardAPIKeysUsageRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
return await UsageService.dashboard_api_keys_usage(db, user, body.api_key_ids)

View File

@@ -25,6 +25,15 @@ class Settings(BaseSettings):
jwt_access_expire_minutes: int = 30
jwt_refresh_expire_days: int = 7
# sub2api upstream
sub2api_base_url: str = "http://127.0.0.1:8080"
sub2api_admin_token: str = ""
sub2api_request_timeout: float = 10.0
# Fernet key (urlsafe base64, 44 chars). Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
token_encryption_key: str = ""
@property
def database_url(self) -> str:
return (
@@ -32,6 +41,10 @@ class Settings(BaseSettings):
f"@{self.db_host}:{self.db_port}/{self.db_name}"
)
@property
def sub2api_api_prefix(self) -> str:
return self.sub2api_base_url.rstrip("/") + "/api/v1"
class Config:
env_file = ".env"
env_prefix = "SD_"

29
app/core/crypto.py Normal file
View File

@@ -0,0 +1,29 @@
from cryptography.fernet import Fernet, InvalidToken
from app.config.settings import settings
def _get_fernet() -> Fernet:
key = settings.token_encryption_key
if not key:
raise RuntimeError(
"SD_TOKEN_ENCRYPTION_KEY is not configured. "
"Generate one with: python -c \"from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())\""
)
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt_token(plaintext: str) -> str:
if not plaintext:
return ""
return _get_fernet().encrypt(plaintext.encode()).decode()
def decrypt_token(ciphertext: str) -> str:
if not ciphertext:
return ""
try:
return _get_fernet().decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise ValueError("Failed to decrypt token (key mismatch or corrupted value)") from exc

View File

@@ -1,10 +1,12 @@
from decimal import Decimal
from pydantic import BaseModel, EmailStr
from datetime import datetime, date
from typing import Optional, List
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
# ── Auth ──
# ── Auth ──────────────────────────────────────────────────────────────
class RegisterRequest(BaseModel):
email: str
@@ -36,144 +38,59 @@ class ResetPasswordRequest(BaseModel):
class UserResponse(BaseModel):
"""Local user view. Balance mirrored from sub2api when available."""
id: str
email: str
balance: Decimal
status: str
created_at: datetime
sub2api_user_id: Optional[int] = None
balance: float = 0.0
class Config:
from_attributes = True
# ── API Key ──
# ── API Key (shapes match sub2api dto.APIKey 1:1) ─────────────────────
class CreateKeyRequest(BaseModel):
name: str = ""
class ApiKeyResponse(BaseModel):
id: str
name: str
key_prefix: str
key_suffix: str
status: str
created_at: datetime
class Config:
from_attributes = True
group_id: Optional[int] = None
custom_key: Optional[str] = None
ip_whitelist: List[str] = []
ip_blacklist: List[str] = []
quota: Optional[float] = None
expires_in_days: Optional[int] = None
rate_limit_5h: Optional[float] = None
rate_limit_1d: Optional[float] = None
rate_limit_7d: Optional[float] = None
class ApiKeyCreatedResponse(BaseModel):
id: str
name: str
key: str
key_prefix: str
key_suffix: str
created_at: datetime
class UpdateKeyRequest(BaseModel):
name: Optional[str] = None
group_id: Optional[int] = None
status: Optional[str] = None
ip_whitelist: Optional[List[str]] = None
ip_blacklist: Optional[List[str]] = None
quota: Optional[float] = None
expires_at: Optional[str] = None
reset_quota: Optional[bool] = None
rate_limit_5h: Optional[float] = None
rate_limit_1d: Optional[float] = None
rate_limit_7d: Optional[float] = None
reset_rate_limit_usage: Optional[bool] = None
# ── Wallet ──
# ── Usage ─────────────────────────────────────────────────────────────
class RedeemCodeRequest(BaseModel):
code: str
class DashboardAPIKeysUsageRequest(BaseModel):
api_key_ids: List[int]
class TransactionResponse(BaseModel):
id: str
type: str
amount: Decimal
balance_after: Decimal
reference_id: str
created_at: datetime
class Config:
from_attributes = True
class BalanceResponse(BaseModel):
balance: Decimal
# ── Models ──
class ModelPricingResponse(BaseModel):
id: int
model_name: str
provider: str
input_price_per_1k: Decimal
output_price_per_1k: Decimal
status: str
updated_at: datetime
class Config:
from_attributes = True
# ── Example (legacy) ──
class ExampleCreate(BaseModel):
name: str
description: str = ""
class ExampleResponse(BaseModel):
id: str
name: str
description: str
created_at: datetime
# ── Common ──
# ── Common ────────────────────────────────────────────────────────────
class MessageResponse(BaseModel):
message: str
# ── Usage ──
class UsageSummaryResponse(BaseModel):
today_tokens: int
today_cost: Decimal
month_tokens: int
month_cost: Decimal
total_requests: int
class DailyUsageResponse(BaseModel):
date: date
total_tokens: int
cost: Decimal
requests: int
class ModelUsageResponse(BaseModel):
model: str
total_tokens: int
cost: Decimal
requests: int
class KeyUsageResponse(BaseModel):
key_id: str
key_name: str
key_prefix: str
key_suffix: str
total_tokens: int
cost: Decimal
requests: int
class UsageLogResponse(BaseModel):
id: int
key_id: str
model: str
prompt_tokens: int
completion_tokens: int
total_tokens: int
cost: Decimal
request_time: datetime
status: str
class Config:
from_attributes = True
JSONDict = Dict[str, Any]

View File

View File

@@ -0,0 +1,18 @@
from app.integrations.sub2api.client import (
Sub2APIError,
Sub2APIReauthRequired,
Sub2APITransportError,
get_client,
close_client,
)
from app.integrations.sub2api import admin, user
__all__ = [
"Sub2APIError",
"Sub2APIReauthRequired",
"Sub2APITransportError",
"get_client",
"close_client",
"admin",
"user",
]

View File

@@ -0,0 +1,97 @@
"""Admin-token-authenticated calls to sub2api.
Used for:
- User lifecycle sync (create / update / delete / lookup)
- Reading a user's API keys and usage (admin endpoints only support reads)
"""
from __future__ import annotations
from typing import Any
from app.integrations.sub2api.client import admin_request
# ── Users ─────────────────────────────────────────────────────────────
async def create_user(
*,
email: str,
password: str,
username: str = "",
notes: str = "",
balance: float = 0,
concurrency: int = 0,
allowed_groups: list[int] | None = None,
) -> dict[str, Any]:
return await admin_request(
"POST",
"/admin/users",
json={
"email": email,
"password": password,
"username": username,
"notes": notes,
"balance": balance,
"concurrency": concurrency,
"allowed_groups": allowed_groups or [],
},
)
async def update_user(user_id: int, **fields: Any) -> dict[str, Any]:
"""Partial update. Only non-None fields are sent."""
payload = {k: v for k, v in fields.items() if v is not None}
return await admin_request("PUT", f"/admin/users/{user_id}", json=payload)
async def delete_user(user_id: int) -> None:
await admin_request("DELETE", f"/admin/users/{user_id}")
async def get_user(user_id: int) -> dict[str, Any]:
return await admin_request("GET", f"/admin/users/{user_id}")
async def find_user_by_email(email: str) -> dict[str, Any] | None:
data = await admin_request(
"GET",
"/admin/users",
params={"search": email, "page": 1, "page_size": 5},
)
items = (data or {}).get("items") or []
for item in items:
if (item.get("email") or "").lower() == email.lower():
return item
return None
# ── API Keys (read-only from admin side) ──────────────────────────────
async def list_user_api_keys(
user_id: int,
*,
page: int = 1,
page_size: int = 20,
sort_by: str = "created_at",
sort_order: str = "desc",
) -> dict[str, Any]:
return await admin_request(
"GET",
f"/admin/users/{user_id}/api-keys",
params={
"page": page,
"page_size": page_size,
"sort_by": sort_by,
"sort_order": sort_order,
},
)
# ── Usage (admin view per user) ───────────────────────────────────────
async def get_user_usage_stats(user_id: int, period: str = "month") -> dict[str, Any]:
return await admin_request(
"GET",
f"/admin/users/{user_id}/usage",
params={"period": period},
)

View File

@@ -0,0 +1,131 @@
"""Low-level HTTP client for sub2api.
Responsibilities:
- Singleton httpx.AsyncClient with configured base URL and timeout.
- Uniform envelope parsing: {code, message, reason, metadata, data}.
- Error translation to domain exceptions.
"""
from __future__ import annotations
import logging
from typing import Any, Mapping
import httpx
from app.config.settings import settings
logger = logging.getLogger(__name__)
class Sub2APIError(Exception):
"""sub2api returned a non-zero envelope code or HTTP error."""
def __init__(
self,
code: int,
message: str,
reason: str = "",
metadata: Mapping[str, str] | None = None,
http_status: int | None = None,
) -> None:
super().__init__(f"[{code}] {message}" + (f" ({reason})" if reason else ""))
self.code = code
self.message = message
self.reason = reason
self.metadata = dict(metadata) if metadata else {}
self.http_status = http_status
class Sub2APIReauthRequired(Sub2APIError):
"""User-level token invalid/expired; caller must re-authenticate with password."""
class Sub2APITransportError(Exception):
"""Network / timeout / invalid JSON etc."""
_client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
base_url=settings.sub2api_api_prefix,
timeout=settings.sub2api_request_timeout,
)
return _client
async def close_client() -> None:
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
def _parse_envelope(resp: httpx.Response) -> Any:
try:
body = resp.json()
except ValueError as exc:
raise Sub2APITransportError(
f"sub2api returned non-JSON body (status={resp.status_code}): {resp.text[:200]}"
) from exc
code = body.get("code", resp.status_code)
message = body.get("message", "")
reason = body.get("reason", "") or ""
metadata = body.get("metadata") or {}
if code == 0:
return body.get("data")
# Token invalidation signals from sub2api admin middleware
if reason in {"TOKEN_EXPIRED", "INVALID_TOKEN", "TOKEN_REVOKED", "USER_INACTIVE"}:
raise Sub2APIReauthRequired(code, message, reason, metadata, resp.status_code)
raise Sub2APIError(code, message, reason, metadata, resp.status_code)
async def request(
method: str,
path: str,
*,
json: Any = None,
params: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Execute a sub2api call and return the unwrapped ``data`` field."""
client = get_client()
try:
resp = await client.request(
method,
path,
json=json,
params={k: v for k, v in (params or {}).items() if v is not None},
headers=headers,
)
except httpx.TimeoutException as exc:
raise Sub2APITransportError(f"sub2api timeout on {method} {path}") from exc
except httpx.HTTPError as exc:
raise Sub2APITransportError(f"sub2api transport error on {method} {path}: {exc}") from exc
return _parse_envelope(resp)
async def admin_request(method: str, path: str, **kwargs: Any) -> Any:
"""Issue a request authenticated with the admin API key."""
if not settings.sub2api_admin_token:
raise RuntimeError("SD_SUB2API_ADMIN_TOKEN is not configured")
headers = dict(kwargs.pop("headers", None) or {})
headers["x-api-key"] = settings.sub2api_admin_token
return await request(method, path, headers=headers, **kwargs)
async def user_request(access_token: str, method: str, path: str, **kwargs: Any) -> Any:
"""Issue a request authenticated with a user's JWT access token."""
if not access_token:
raise Sub2APIReauthRequired(401, "missing user access token", "MISSING_TOKEN")
headers = dict(kwargs.pop("headers", None) or {})
headers["Authorization"] = f"Bearer {access_token}"
return await request(method, path, headers=headers, **kwargs)

View File

@@ -0,0 +1,96 @@
"""User-JWT-authenticated calls to sub2api.
Used for:
- Auth login / refresh / me (to obtain tokens for BFF proxying)
- API Key CRUD (admin endpoints cannot create / delete keys)
- Usage list / detail / dashboard for the authenticated user
"""
from __future__ import annotations
from typing import Any
from app.integrations.sub2api.client import request, user_request
# ── Auth (public; no Bearer required) ─────────────────────────────────
async def login(email: str, password: str) -> dict[str, Any]:
"""Returns AuthResponse: {access_token, refresh_token, expires_in, token_type, user}."""
return await request(
"POST",
"/auth/login",
json={"email": email, "password": password, "turnstile_token": ""},
)
async def refresh_tokens(refresh_token: str) -> dict[str, Any]:
"""Returns RefreshTokenResponse: {access_token, refresh_token, expires_in, token_type}."""
return await request(
"POST",
"/auth/refresh",
json={"refresh_token": refresh_token},
)
async def logout(refresh_token: str | None = None) -> dict[str, Any]:
body = {"refresh_token": refresh_token} if refresh_token else {}
return await request("POST", "/auth/logout", json=body)
# ── API Key CRUD ──────────────────────────────────────────────────────
async def create_key(access_token: str, payload: dict[str, Any]) -> dict[str, Any]:
return await user_request(access_token, "POST", "/keys", json=payload)
async def update_key(access_token: str, key_id: int, payload: dict[str, Any]) -> dict[str, Any]:
return await user_request(access_token, "PUT", f"/keys/{key_id}", json=payload)
async def delete_key(access_token: str, key_id: int) -> dict[str, Any]:
return await user_request(access_token, "DELETE", f"/keys/{key_id}")
async def get_key(access_token: str, key_id: int) -> dict[str, Any]:
return await user_request(access_token, "GET", f"/keys/{key_id}")
# ── Groups ────────────────────────────────────────────────────────────
async def list_available_groups(access_token: str) -> list[dict[str, Any]]:
return await user_request(access_token, "GET", "/groups/available")
async def get_user_group_rates(access_token: str) -> dict[str, float]:
return await user_request(access_token, "GET", "/groups/rates")
# ── Usage (user view) ─────────────────────────────────────────────────
async def list_usage(access_token: str, **params: Any) -> dict[str, Any]:
return await user_request(access_token, "GET", "/usage", params=params)
async def usage_stats(access_token: str, **params: Any) -> dict[str, Any]:
return await user_request(access_token, "GET", "/usage/stats", params=params)
async def dashboard_stats(access_token: str) -> dict[str, Any]:
return await user_request(access_token, "GET", "/usage/dashboard/stats")
async def dashboard_trend(access_token: str, **params: Any) -> dict[str, Any]:
return await user_request(access_token, "GET", "/usage/dashboard/trend", params=params)
async def dashboard_models(access_token: str, **params: Any) -> dict[str, Any]:
return await user_request(access_token, "GET", "/usage/dashboard/models", params=params)
async def dashboard_api_keys_usage(access_token: str, api_key_ids: list[int]) -> dict[str, Any]:
return await user_request(
access_token,
"POST",
"/usage/dashboard/api-keys-usage",
json={"api_key_ids": api_key_ids},
)

View File

@@ -3,7 +3,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.api.v1 import health, example, auth, keys, models as models_api, wallet, usage
from app.api.v1 import health, example, auth, keys, usage
from app.config.settings import settings
from app.core.database import init_db
import os
@@ -35,8 +35,6 @@ def create_app() -> FastAPI:
app.include_router(health.router, prefix="/api/v1", tags=["health"])
app.include_router(auth.router, prefix="/api/v1")
app.include_router(keys.router, prefix="/api/v1")
app.include_router(models_api.router, prefix="/api/v1")
app.include_router(wallet.router, prefix="/api/v1")
app.include_router(usage.router, prefix="/api/v1")
app.include_router(example.router, prefix="/api/v1", tags=["example"])

View File

@@ -2,11 +2,10 @@ from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import List
from typing import Optional
from sqlalchemy import String, Text, Integer, BigInteger, DateTime, Numeric, Enum, ForeignKey, Index, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Text, BigInteger, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@@ -17,71 +16,12 @@ class User(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
balance: Mapped[Decimal] = mapped_column(Numeric(16, 6), default=Decimal("0"))
status: Mapped[str] = mapped_column(String(20), default="active") # active / disabled
status: Mapped[str] = mapped_column(String(20), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
api_keys: Mapped[List[ApiKey]] = relationship(back_populates="user")
transactions: Mapped[List[Transaction]] = relationship(back_populates="user")
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), default="")
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) # SHA256
key_prefix: Mapped[str] = mapped_column(String(10), nullable=False)
key_suffix: Mapped[str] = mapped_column(String(10), nullable=False)
status: Mapped[str] = mapped_column(String(20), default="active") # active / revoked
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
user: Mapped["User"] = relationship(back_populates="api_keys")
class UsageLog(Base):
__tablename__ = "usage_logs"
__table_args__ = (
Index("ix_usage_user_time", "user_id", "request_time"),
Index("ix_usage_model", "user_id", "model"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
key_id: Mapped[str] = mapped_column(String(36), ForeignKey("api_keys.id"), nullable=False)
model: Mapped[str] = mapped_column(String(100), nullable=False)
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0)
completion_tokens: Mapped[int] = mapped_column(Integer, default=0)
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
cost: Mapped[Decimal] = mapped_column(Numeric(16, 6), default=Decimal("0"))
request_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
response_time: Mapped[datetime] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="success") # success / error
class Transaction(Base):
__tablename__ = "transactions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(20), nullable=False) # topup / consume / refund
amount: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False)
balance_after: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False)
reference_id: Mapped[str] = mapped_column(String(100), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
user: Mapped["User"] = relationship(back_populates="transactions")
class ModelPricing(Base):
__tablename__ = "models_pricing"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
model_name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
input_price_per_1k: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False)
output_price_per_1k: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False)
status: Mapped[str] = mapped_column(String(20), default="available") # available / offline
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# sub2api 用户体系映射
sub2api_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
sub2api_refresh_token_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sub2api_access_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sub2api_access_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)

View File

@@ -1,11 +1,41 @@
"""Auth service: local user table + sub2api sync.
Registration and login keep two systems in sync:
- superDream local User row (owns password hash, superDream JWT)
- sub2api User (owns API keys, usage, balance)
On registration, sub2api user is created via admin API; then we login to sub2api
with the same password to obtain and store a refresh_token. Any sub2api failure
rolls back the local insert so the two systems never drift apart silently.
On login, after local password verification we also refresh the stored sub2api
tokens (by re-logging in to sub2api) so that subsequent proxied calls have a
valid refresh_token without ever persisting the plaintext password.
"""
from __future__ import annotations
import logging
import re
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from app.core.exceptions import BadRequestError, UnauthorizedError
from app.core.security import (
create_access_token,
create_refresh_token,
create_reset_token,
decode_token,
hash_password,
verify_password,
)
from app.integrations.sub2api import admin as sub2api_admin
from app.integrations.sub2api import user as sub2api_user
from app.integrations.sub2api.client import Sub2APIError, Sub2APITransportError
from app.models import User
from app.services.sub2api_session import store_tokens
logger = logging.getLogger(__name__)
def _validate_password(password: str) -> None:
@@ -17,8 +47,14 @@ def _validate_password(password: str) -> None:
raise BadRequestError("密码需同时包含字母和数字")
class AuthService:
def _upstream_failure(action: str, exc: Exception) -> BadRequestError:
logger.error("sub2api %s failed: %s", action, exc)
if isinstance(exc, Sub2APIError):
return BadRequestError(f"上游同步失败:{exc.message or exc.reason or 'unknown'}")
return BadRequestError("上游服务不可用,请稍后重试")
class AuthService:
@staticmethod
async def register(db: AsyncSession, email: str, password: str) -> User:
_validate_password(password)
@@ -27,10 +63,44 @@ class AuthService:
if existing.scalar_one_or_none():
raise BadRequestError("Email already registered")
user = User(email=email, password_hash=hash_password(password))
# 1. Create the sub2api user first (so a conflict on their side halts us
# before we touch our DB).
try:
remote = await sub2api_admin.create_user(email=email, password=password)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _upstream_failure("create_user", exc) from exc
remote_user_id = int(remote.get("id"))
# 2. Grab a token pair by logging in as the new user.
try:
tokens = await sub2api_user.login(email, password)
except (Sub2APIError, Sub2APITransportError) as exc:
# Roll back the remote user so re-registration works.
try:
await sub2api_admin.delete_user(remote_user_id)
except Exception: # pragma: no cover - best-effort cleanup
logger.exception("failed to roll back sub2api user %s", remote_user_id)
raise _upstream_failure("login_after_register", exc) from exc
# 3. Persist the local user with sub2api references.
user = User(
email=email,
password_hash=hash_password(password),
sub2api_user_id=remote_user_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
try:
await db.flush()
await store_tokens(db, user, tokens)
except Exception:
await db.rollback()
try:
await sub2api_admin.delete_user(remote_user_id)
except Exception: # pragma: no cover
logger.exception("failed to roll back sub2api user %s", remote_user_id)
raise
return user
@staticmethod
@@ -42,6 +112,18 @@ class AuthService:
if user.status != "active":
raise UnauthorizedError("Account is disabled")
# Refresh stored sub2api tokens with the same password. If sub2api is
# down we still allow local login; proxied calls will surface 503 later.
try:
tokens = await sub2api_user.login(email, password)
if not user.sub2api_user_id and tokens.get("user", {}).get("id"):
user.sub2api_user_id = int(tokens["user"]["id"])
await store_tokens(db, user, tokens)
except Sub2APIError as exc:
logger.warning("sub2api login failed for %s: %s", email, exc)
except Sub2APITransportError as exc:
logger.warning("sub2api unreachable during login: %s", exc)
return {
"access_token": create_access_token(user.id),
"refresh_token": create_refresh_token(user.id),
@@ -65,14 +147,10 @@ class AuthService:
@staticmethod
async def forgot_password(db: AsyncSession, email: str) -> str:
"""Generate a password reset token. MVP: returns the token directly (production: send via email)."""
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user:
# Don't reveal whether email exists
return ""
from app.core.security import create_reset_token
return create_reset_token(user.id)
@staticmethod
@@ -88,4 +166,16 @@ class AuthService:
raise BadRequestError("User not found")
user.password_hash = hash_password(new_password)
# Sync the new password to sub2api so subsequent proxied calls continue
# to work. If this fails the user can still log in locally but won't be
# able to mutate keys until the systems re-converge.
if user.sub2api_user_id:
try:
await sub2api_admin.update_user(user.sub2api_user_id, password=new_password)
tokens = await sub2api_user.login(user.email, new_password)
await store_tokens(db, user, tokens)
except (Sub2APIError, Sub2APITransportError) as exc:
logger.error("sub2api password sync failed for user %s: %s", user.id, exc)
await db.commit()

View File

@@ -1,84 +1,111 @@
import hashlib
import secrets
import uuid
"""API Key service: proxied to sub2api.
from sqlalchemy import select, func
- Reads use the admin API key to call ``/admin/users/:id/api-keys``.
- Writes (create/update/delete) require the user's own sub2api JWT; we fetch
one via ``ensure_access_token`` using the stored refresh token.
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import ApiKey
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.exceptions import BadRequestError
from app.integrations.sub2api import admin as sub2api_admin
from app.integrations.sub2api import user as sub2api_user
from app.integrations.sub2api.client import (
Sub2APIError,
Sub2APIReauthRequired,
Sub2APITransportError,
)
from app.models import User
from app.services.sub2api_session import ensure_access_token
MAX_KEYS_PER_USER = 5
KEY_PREFIX = "sk-sd-"
def _require_sub2api_binding(user: User) -> int:
if not user.sub2api_user_id:
raise BadRequestError("账号未完成 sub2api 绑定,请重新登录")
return user.sub2api_user_id
def _translate_upstream(exc: Exception) -> HTTPException:
if isinstance(exc, Sub2APIReauthRequired):
return HTTPException(status_code=401, detail="sub2api_reauth_required")
if isinstance(exc, Sub2APIError):
status = exc.http_status or 502
if status < 400 or status >= 600:
status = 502
return HTTPException(status_code=status, detail=exc.message or exc.reason or "upstream_error")
return HTTPException(status_code=504, detail="upstream_timeout")
class KeyService:
@staticmethod
def _generate_key() -> str:
return KEY_PREFIX + secrets.token_urlsafe(36)
@staticmethod
def _hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode()).hexdigest()
@staticmethod
async def list_keys(db: AsyncSession, user_id: str) -> list:
result = await db.execute(
select(ApiKey)
.where(ApiKey.user_id == user_id, ApiKey.status == "active")
.order_by(ApiKey.created_at.desc())
async def list_keys(
db: AsyncSession,
user: User,
*,
page: int = 1,
page_size: int = 20,
sort_by: str = "created_at",
sort_order: str = "desc",
) -> dict[str, Any]:
uid = _require_sub2api_binding(user)
try:
return await sub2api_admin.list_user_api_keys(
uid,
page=page,
page_size=page_size,
sort_by=sort_by,
sort_order=sort_order,
)
return result.scalars().all()
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
async def create_key(db: AsyncSession, user_id: str, name: str = "") -> dict:
# Check limit
count_result = await db.execute(
select(func.count()).select_from(ApiKey)
.where(ApiKey.user_id == user_id, ApiKey.status == "active")
)
count = count_result.scalar()
if count >= MAX_KEYS_PER_USER:
raise BadRequestError(f"最多创建 {MAX_KEYS_PER_USER} 个 Key")
raw_key = KeyService._generate_key()
key_hash = KeyService._hash_key(raw_key)
# prefix/suffix for masked display (after "sk-sd-")
body = raw_key[len(KEY_PREFIX):]
key_prefix = body[:4]
key_suffix = body[-4:]
api_key = ApiKey(
id=str(uuid.uuid4()),
user_id=user_id,
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
key_suffix=key_suffix,
)
db.add(api_key)
await db.commit()
await db.refresh(api_key)
return {
"id": api_key.id,
"name": api_key.name,
"key": raw_key, # only returned once
"key_prefix": key_prefix,
"key_suffix": key_suffix,
"created_at": api_key.created_at,
}
async def create_key(db: AsyncSession, user: User, payload: dict[str, Any]) -> dict[str, Any]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.create_key(token, payload)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
async def delete_key(db: AsyncSession, user_id: str, key_id: str) -> None:
result = await db.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id)
)
api_key = result.scalar_one_or_none()
if not api_key:
raise NotFoundError("Key not found")
async def update_key(
db: AsyncSession, user: User, key_id: int, payload: dict[str, Any]
) -> dict[str, Any]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.update_key(token, key_id, payload)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
api_key.status = "revoked"
await db.commit()
@staticmethod
async def delete_key(db: AsyncSession, user: User, key_id: int) -> dict[str, Any]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.delete_key(token, key_id)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
async def get_key(db: AsyncSession, user: User, key_id: int) -> dict[str, Any]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.get_key(token, key_id)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
async def available_groups(db: AsyncSession, user: User) -> list[dict[str, Any]]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.list_available_groups(token)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc

View File

@@ -0,0 +1,74 @@
"""Helpers to obtain a fresh sub2api user access token from stored state.
The User row carries:
- sub2api_access_token + sub2api_access_expires_at (cache)
- sub2api_refresh_token_enc (Fernet-encrypted refresh_token)
``ensure_access_token`` returns a usable access_token, refreshing via sub2api
``/auth/refresh`` when the cached one is expired. On refresh failure the caller
receives ``Sub2APIReauthRequired`` and should surface 401 so the frontend can
prompt re-login.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import decrypt_token, encrypt_token
from app.integrations.sub2api import user as sub2api_user
from app.integrations.sub2api.client import Sub2APIError, Sub2APIReauthRequired
from app.models import User
_CLOCK_SKEW = timedelta(seconds=60)
def _utcnow() -> datetime:
return datetime.now(tz=timezone.utc)
def _is_access_fresh(user: User) -> bool:
if not user.sub2api_access_token or not user.sub2api_access_expires_at:
return False
expires_at = user.sub2api_access_expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return expires_at - _CLOCK_SKEW > _utcnow()
async def store_tokens(db: AsyncSession, user: User, token_response: dict) -> None:
"""Persist a fresh token pair (from /auth/login or /auth/refresh)."""
access = token_response.get("access_token") or ""
refresh = token_response.get("refresh_token") or ""
expires_in = int(token_response.get("expires_in") or 3600)
user.sub2api_access_token = access
user.sub2api_access_expires_at = _utcnow() + timedelta(seconds=expires_in)
if refresh:
user.sub2api_refresh_token_enc = encrypt_token(refresh)
await db.commit()
await db.refresh(user)
async def ensure_access_token(db: AsyncSession, user: User) -> str:
"""Return a usable sub2api access_token, refreshing if necessary."""
if _is_access_fresh(user):
return user.sub2api_access_token # type: ignore[return-value]
if not user.sub2api_refresh_token_enc:
raise Sub2APIReauthRequired(401, "no refresh token stored", "NO_REFRESH_TOKEN")
try:
refresh_plain = decrypt_token(user.sub2api_refresh_token_enc)
except ValueError as exc:
raise Sub2APIReauthRequired(401, str(exc), "DECRYPT_FAILED") from exc
try:
token_response = await sub2api_user.refresh_tokens(refresh_plain)
except Sub2APIError as exc:
raise Sub2APIReauthRequired(
exc.code, exc.message, exc.reason or "REFRESH_FAILED", exc.metadata
) from exc
await store_tokens(db, user, token_response)
return user.sub2api_access_token # type: ignore[return-value]

View File

@@ -1,139 +1,92 @@
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
"""Usage service: fully proxied to sub2api (user JWT for user-scoped endpoints)."""
from __future__ import annotations
from sqlalchemy import select, func, cast, Date
from typing import Any
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import UsageLog, ApiKey
from app.core.exceptions import BadRequestError
from app.integrations.sub2api import user as sub2api_user
from app.integrations.sub2api.client import (
Sub2APIError,
Sub2APIReauthRequired,
Sub2APITransportError,
)
from app.models import User
from app.services.sub2api_session import ensure_access_token
def _require_binding(user: User) -> int:
if not user.sub2api_user_id:
raise BadRequestError("账号未完成 sub2api 绑定,请重新登录")
return user.sub2api_user_id
def _translate(exc: Exception) -> HTTPException:
if isinstance(exc, Sub2APIReauthRequired):
return HTTPException(status_code=401, detail="sub2api_reauth_required")
if isinstance(exc, Sub2APIError):
status = exc.http_status or 502
if status < 400 or status >= 600:
status = 502
return HTTPException(status_code=status, detail=exc.message or exc.reason or "upstream_error")
return HTTPException(status_code=504, detail="upstream_timeout")
class UsageService:
@staticmethod
async def list_logs(db: AsyncSession, user: User, **params: Any) -> dict[str, Any]:
_require_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.list_usage(token, **params)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate(exc) from exc
@staticmethod
async def summary(db: AsyncSession, user_id: str) -> dict:
today_start = datetime.combine(date.today(), datetime.min.time())
month_start = today_start.replace(day=1)
# Today
today_row = (await db.execute(
select(
func.coalesce(func.sum(UsageLog.total_tokens), 0),
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
).where(UsageLog.user_id == user_id, UsageLog.request_time >= today_start)
)).one()
# This month
month_row = (await db.execute(
select(
func.coalesce(func.sum(UsageLog.total_tokens), 0),
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
func.count(),
).where(UsageLog.user_id == user_id, UsageLog.request_time >= month_start)
)).one()
return {
"today_tokens": int(today_row[0]),
"today_cost": today_row[1],
"month_tokens": int(month_row[0]),
"month_cost": month_row[1],
"total_requests": int(month_row[2]),
}
async def stats(db: AsyncSession, user: User, **params: Any) -> dict[str, Any]:
_require_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.usage_stats(token, **params)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate(exc) from exc
@staticmethod
async def daily(
db: AsyncSession, user_id: str,
start: Optional[date] = None, end: Optional[date] = None,
) -> list:
if not start:
start = date.today() - timedelta(days=29)
if not end:
end = date.today()
day_col = cast(UsageLog.request_time, Date).label("day")
result = await db.execute(
select(
day_col,
func.coalesce(func.sum(UsageLog.total_tokens), 0),
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
func.count(),
)
.where(
UsageLog.user_id == user_id,
cast(UsageLog.request_time, Date) >= start,
cast(UsageLog.request_time, Date) <= end,
)
.group_by(day_col)
.order_by(day_col)
)
return [
{"date": row[0], "total_tokens": int(row[1]), "cost": row[2], "requests": int(row[3])}
for row in result.all()
]
async def dashboard_stats(db: AsyncSession, user: User) -> dict[str, Any]:
_require_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.dashboard_stats(token)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate(exc) from exc
@staticmethod
async def by_model(db: AsyncSession, user_id: str) -> list:
result = await db.execute(
select(
UsageLog.model,
func.coalesce(func.sum(UsageLog.total_tokens), 0),
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
func.count(),
)
.where(UsageLog.user_id == user_id)
.group_by(UsageLog.model)
.order_by(func.sum(UsageLog.cost).desc())
)
return [
{"model": row[0], "total_tokens": int(row[1]), "cost": row[2], "requests": int(row[3])}
for row in result.all()
]
async def dashboard_trend(db: AsyncSession, user: User, **params: Any) -> dict[str, Any]:
_require_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.dashboard_trend(token, **params)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate(exc) from exc
@staticmethod
async def by_key(db: AsyncSession, user_id: str) -> list:
result = await db.execute(
select(
UsageLog.key_id,
ApiKey.name,
ApiKey.key_prefix,
ApiKey.key_suffix,
func.coalesce(func.sum(UsageLog.total_tokens), 0),
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
func.count(),
)
.join(ApiKey, UsageLog.key_id == ApiKey.id)
.where(UsageLog.user_id == user_id)
.group_by(UsageLog.key_id, ApiKey.name, ApiKey.key_prefix, ApiKey.key_suffix)
.order_by(func.sum(UsageLog.cost).desc())
)
return [
{
"key_id": row[0], "key_name": row[1] or "",
"key_prefix": row[2], "key_suffix": row[3],
"total_tokens": int(row[4]), "cost": row[5], "requests": int(row[6]),
}
for row in result.all()
]
async def dashboard_models(db: AsyncSession, user: User, **params: Any) -> dict[str, Any]:
_require_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.dashboard_models(token, **params)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate(exc) from exc
@staticmethod
async def logs(
db: AsyncSession, user_id: str,
page: int = 1, size: int = 20,
model: Optional[str] = None,
key_id: Optional[str] = None,
start: Optional[date] = None,
end: Optional[date] = None,
) -> list:
q = select(UsageLog).where(UsageLog.user_id == user_id)
if model:
q = q.where(UsageLog.model == model)
if key_id:
q = q.where(UsageLog.key_id == key_id)
if start:
q = q.where(UsageLog.request_time >= datetime.combine(start, datetime.min.time()))
if end:
q = q.where(UsageLog.request_time < datetime.combine(end + timedelta(days=1), datetime.min.time()))
q = q.order_by(UsageLog.request_time.desc()).offset((page - 1) * size).limit(size)
result = await db.execute(q)
return result.scalars().all()
async def dashboard_api_keys_usage(
db: AsyncSession, user: User, api_key_ids: list[int]
) -> dict[str, Any]:
_require_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.dashboard_api_keys_usage(token, api_key_ids)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate(exc) from exc

View File

@@ -19,7 +19,7 @@ services:
backend:
container_name: superdreamfront
image: superdreamfront:0.0.1
image: superdreamfront:0.0.2
build:
context: .
dockerfile: Dockerfile

339
docs/sub2api_api.md Normal file
View File

@@ -0,0 +1,339 @@
# sub2api 接口对接规范
本文档记录 superDreamFront 与 sub2api 对接所使用的全部 HTTP 接口,以及 BFF 层superDreamFront FastAPI各服务的调用策略。
> 对应的 sub2api 源码位置:
> - 路由:`sub2api/backend/internal/server/routes/{auth,user,admin}.go`
> - 用户侧 handler`sub2api/backend/internal/handler/{auth_handler,api_key_handler,usage_handler,user_handler}.go`
> - 管理员 handler`sub2api/backend/internal/handler/admin/{user_handler,apikey_handler}.go`
> - DTO 类型:`sub2api/backend/internal/handler/dto/types.go`
---
## 1. 通用约定
### 1.1 Base URL
所有接口以 `/api/v1` 为前缀,完整地址由环境变量 `SD_SUB2API_BASE_URL` 决定,例如:
```
SD_SUB2API_BASE_URL = http://127.0.0.1:8080
→ 实际请求http://127.0.0.1:8080/api/v1/...
```
### 1.2 响应 envelope
所有响应统一包一层(来源 `internal/pkg/response/response.go`
```json
{
"code": 0, // 0 表示成功;非 0 为 HTTP 状态码
"message": "success",
"reason": "", // 仅错误时出现
"metadata": null, // 错误附加信息
"data": { ... } // 业务载荷
}
```
分页响应的 `data` 形状固定为:
```json
{
"items": [...],
"total": 1234,
"page": 1,
"page_size": 20,
"pages": 62
}
```
BFF 层建议在 `sub2api.client.request()` 中统一解 envelope
- `code == 0` → 返回 `data`
- `code != 0` → 抛自定义异常 `Sub2APIError(code, message, reason, metadata)`
### 1.3 认证方式
sub2api 有三类鉴权:
| 鉴权类型 | 携带方式 | 用途 |
|---|---|---|
| 公开 | 无 | 注册、登录、忘记密码、验证码 |
| 用户 JWT | `Authorization: Bearer <access_token>` | `/keys``/usage``/user/*` |
| 管理员 | `x-api-key: <admin_api_key>``Authorization: Bearer <admin_jwt>` | `/admin/*` |
> 管理员 API Key 在 sub2api 后台 `Admin 设置 → Admin API Key → 生成` 后拿到,写入 `SD_SUB2API_ADMIN_TOKEN`。
### 1.4 Turnstile
部分公开接口会校验 Cloudflare Turnstile。sub2api 管理员可在设置里关闭;本 BFF 暂不处理 Turnstile若 sub2api 启用会 400。
---
## 2. 认证(公开接口)
### 2.1 发送邮箱验证码
`POST /api/v1/auth/send-verify-code`
请求:
```json
{ "email": "user@example.com", "turnstile_token": "" }
```
响应:
```json
{ "message": "Verification code sent successfully", "countdown": 60 }
```
### 2.2 注册
`POST /api/v1/auth/register`
请求:
```json
{
"email": "user@example.com",
"password": "******", // min 6
"verify_code": "123456", // 若 sub2api 开启邮箱验证码注册
"turnstile_token": "",
"promo_code": "", // 可选注册优惠码
"invitation_code": "" // 可选邀请码
}
```
响应(同 `AuthResponse`
```json
{
"access_token": "...",
"refresh_token": "...",
"expires_in": 3600, // access 有效期(秒)
"token_type": "Bearer",
"user": { ...dto.User }
}
```
### 2.3 登录
`POST /api/v1/auth/login`
请求:
```json
{ "email": "user@example.com", "password": "******", "turnstile_token": "" }
```
若用户未开 2FA响应同注册。
若开启 2FA响应
```json
{ "requires_2fa": true, "temp_token": "...", "user_email_masked": "u**@e**.com" }
```
此时前端需再调 `POST /api/v1/auth/login/2fa`
```json
{ "temp_token": "...", "totp_code": "123456" }
```
### 2.4 刷新 Token
`POST /api/v1/auth/refresh`
```json
// 请求
{ "refresh_token": "..." }
// 响应
{ "access_token": "...", "refresh_token": "...", "expires_in": 3600, "token_type": "Bearer" }
```
注意sub2api 的 refresh_token **一次性使用、每次刷新会 rotate**BFF 取到新 refresh_token 要覆盖旧值。
### 2.5 登出
`POST /api/v1/auth/logout` body 可选 `{"refresh_token": "..."}`,服务端会撤销该 token。
### 2.6 忘记 / 重置密码
- `POST /api/v1/auth/forgot-password` `{"email", "turnstile_token"}`
- `POST /api/v1/auth/reset-password` `{"email", "token", "new_password"}`
### 2.7 当前用户
`GET /api/v1/auth/me` 需要用户 JWT响应 `dto.User + {run_mode}`
---
## 3. API Key用户 JWT
路由前缀 **`/api/v1/keys`**(注意不是 `/api-keys`handler 注释里旧路径是误导)。
### 3.1 列表
`GET /api/v1/keys?page=1&page_size=20&sort_by=created_at&sort_order=desc&search=&status=&group_id=`
响应 `data`(分页 envelope
```json
{
"items": [ { ...dto.APIKey } ],
"total": 5, "page": 1, "page_size": 20, "pages": 1
}
```
`dto.APIKey` 关键字段(完整字段见 `types.go`
```ts
{
id: number,
user_id: number,
key: string, // 完整 key注意sub2api 确实返回明文完整 key不做截断
name: string,
group_id: number | null,
status: "active" | "inactive",
ip_whitelist: string[], ip_blacklist: string[],
last_used_at: string | null,
quota: number, // USD0 = 无限
quota_used: number,
expires_at: string | null,
rate_limit_5h: number, rate_limit_1d: number, rate_limit_7d: number, // 0=不限
usage_5h: number, usage_1d: number, usage_7d: number,
reset_5h_at, reset_1d_at, reset_7d_at: string | null,
created_at, updated_at: string,
group?: dto.Group
}
```
### 3.2 单个查询
`GET /api/v1/keys/:id``dto.APIKey`
### 3.3 新建
`POST /api/v1/keys`
```json
{
"name": "my-key",
"group_id": 1, // 可选
"custom_key": null, // 可选自定义 key
"ip_whitelist": [], "ip_blacklist": [],
"quota": 0, // 0=无限
"expires_in_days": null, // null=不过期
"rate_limit_5h": 0, "rate_limit_1d": 0, "rate_limit_7d": 0
}
```
响应:`dto.APIKey`
### 3.4 更新
`PUT /api/v1/keys/:id`(字段同上,全部可选;特殊:`expires_at=""` 清除过期,`reset_quota=true` 重置已用,`reset_rate_limit_usage=true` 重置限速计数)
### 3.5 删除
`DELETE /api/v1/keys/:id``{ "message": "API key deleted successfully" }`
### 3.6 用户可用分组 / 专属倍率
- `GET /api/v1/groups/available``dto.Group[]`
- `GET /api/v1/groups/rates``{ [groupID: number]: number }`
---
## 4. 用量(用户 JWT
### 4.1 列表
`GET /api/v1/usage?page=&page_size=&api_key_id=&model=&request_type=&stream=&billing_type=&start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&timezone=Asia/Shanghai`
响应 `items: dto.UsageLog[]`(分页)。
`dto.UsageLog` 关键字段:
```ts
{
id, user_id, api_key_id, account_id: number,
request_id, model: string,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost,
request_type: string, // "chat"/"responses"/"messages"/"image" 等
stream: boolean,
duration_ms, first_token_ms: number | null,
image_count: number,
created_at: string
}
```
### 4.2 详情
`GET /api/v1/usage/:id``dto.UsageLog`
### 4.3 统计
`GET /api/v1/usage/stats?period=today|week|month``start_date&end_date`
→ 返回 `service.UsageStats`(求和 + tokens/requests/cost 各纬度汇总;字段见 sub2api `UsageStats` 定义)。
### 4.4 Dashboard
- `GET /api/v1/usage/dashboard/stats`
- `GET /api/v1/usage/dashboard/trend?granularity=day&start_date&end_date`
- `GET /api/v1/usage/dashboard/models?start_date&end_date`
- `POST /api/v1/usage/dashboard/api-keys-usage` body `{ "api_key_ids": [1,2,3] }`
---
## 5. 管理员接口x-api-key
superDream BFF 仅用到以下 admin 接口:
### 5.1 用户
- `POST /api/v1/admin/users` — 注册同步
```json
{ "email", "password", "username", "notes": "from superDream",
"balance": 0, "concurrency": 0, "allowed_groups": [] }
```
→ `dto.AdminUser`
- `PUT /api/v1/admin/users/:id` — 改密/改状态同步(字段均为可选 PATCH 语义)
- `DELETE /api/v1/admin/users/:id` — 删除同步
- `GET /api/v1/admin/users?search=email@x.com` — 查找用户(拿 `id` 用于后续调用)
- `GET /api/v1/admin/users/:id` — 详情
### 5.2 查用户 API Keys / 用量
- `GET /api/v1/admin/users/:id/api-keys?page=&page_size=&sort_by=&sort_order=` → `dto.APIKey[]`(分页)
- `GET /api/v1/admin/users/:id/usage?period=today|week|month` → `UsageStats`
### 5.3 能力缺口
sub2api **admin 路径不提供** 代用户创建/删除/修改 Key 的接口(`registerAdminAPIKeyRoutes` 仅 `PUT /admin/api-keys/:id` 改分组)。
→ Key 的写操作必须使用用户 JWT。
---
## 6. BFF 对接策略
### 6.1 本地 `User` 表扩展字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `sub2api_user_id` | `BIGINT` | 对应 sub2api 的 `dto.User.id` |
| `sub2api_refresh_token_enc` | `TEXT` | Fernet 加密后的 refresh_token |
| `sub2api_access_token` | `TEXT` | 当前有效的 access_token内存级缓存即可但写库便于重启复用 |
| `sub2api_access_expires_at` | `DATETIME` | access_token 过期时间UTC |
### 6.2 鉴权 → Token 获取顺序
每次 BFF 需要代理到 sub2api **用户接口** 时:
1. 如本地缓存 access_token 未过期(预留 60s 余量),直接用
2. 否则用 Fernet 解密 refresh_token调 `/auth/refresh` 换新rotate 后写回)
3. 若 refresh 也 401token 被撤销)→ 返回前端 `401 sub2api_reauth_required`,前端弹窗让用户重新输密码,后端重调 `/auth/login` 重新拿到 token
### 6.3 各业务同步矩阵
| 动作 | 本地 | sub2apiadmin token | sub2api用户 token |
|---|---|---|---|
| 注册 | ①写 userpending | ②`POST /admin/users` 取回 `id` | ③`POST /auth/login` 拿首对 tokens<br>④成功后 commit 本地;任一失败整体回滚 |
| 登录 | ①校验本地密码 → 签本地 JWT | — | ②同步调 `/auth/login` 刷新存储 refresh_token |
| 改密 | ①更新本地 hash | ②`PUT /admin/users/:id` | ③用新密码调 `/auth/login` 替换 token |
| 删用户 | ①级联删 | ②`DELETE /admin/users/:id` | — |
| `GET /keys` | — | `GET /admin/users/:id/api-keys` | — |
| `POST/PUT/DELETE /keys` | — | — | `/keys` 系列 |
| `GET /usage/*` | — | `GET /admin/users/:id/usage` | 兜底:若 admin 接口粒度不够,回退到用户 token 调 `/usage` |
| Dashboard 统计 | 本地聚合 sub2api 返回 | `/admin/users/:id/usage` | `/usage/dashboard/*` |
### 6.4 前端字段替换
替换 `frontend/src/services/` 下的 TS interface使其与 `dto.APIKey` / `dto.UsageLog` / `dto.User` 完全一致(字段名用 snake_case 保留,不转 camelCase避免映射层。具体列在实现阶段列出 diff。
### 6.5 错误映射
| sub2api 返回 | BFF 抛出 | 前端显示 |
|---|---|---|
| `code=400` | `HTTPException 400` + `reason` | 表单校验提示 |
| `code=401 INVALID_TOKEN/TOKEN_EXPIRED` | 触发 refresh 流;失败抛 `401 sub2api_reauth_required` | 弹框"请重新登录" |
| `code=403` | `403` | "没有权限" |
| `code=404` | `404` | "资源不存在" |
| `code>=500` | `502 upstream_error` + 日志 | "上游服务异常" |
| 网络异常 | `504 upstream_timeout` | "请求超时" |
---
## 7. 新增配置项
写入 `app/config/settings.py`
```python
sub2api_base_url: str = "http://127.0.0.1:8080"
sub2api_admin_token: str = "" # sub2api Admin API Key
sub2api_request_timeout: float = 10.0 # 秒
sub2api_turnstile_bypass: bool = True # 是否绕过 turnstile需 sub2api 端也关)
token_encryption_key: str = "" # Fernet key44 字节 urlsafe base64
```
生成 Fernet key
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
`requirements.txt` 增加:`httpx>=0.27` `cryptography>=42`

View File

@@ -1,22 +1,34 @@
import { useState, useEffect } from "react";
import { keyService, ApiKeyInfo } from "../../services/keyService";
import { keyService, ApiKey } from "../../services/keyService";
function maskKey(key: string): string {
if (!key) return "";
if (key.length <= 12) return key;
return `${key.slice(0, 8)}...${key.slice(-4)}`;
}
function formatQuota(used: number, total: number): string {
if (total === 0) return `$${used.toFixed(4)} / 无限`;
return `$${used.toFixed(4)} / $${total.toFixed(2)}`;
}
export default function Keys() {
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [name, setName] = useState("");
const [quota, setQuota] = useState<string>("0");
const [newKey, setNewKey] = useState("");
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchKeys = async () => {
try {
const data = await keyService.list();
setKeys(data);
} catch (err: any) {
setError(err.message);
const page = await keyService.list({ page_size: 100 });
setKeys(page.items);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
@@ -29,26 +41,30 @@ export default function Keys() {
setNewKey("");
setLoading(true);
try {
const res = await keyService.create(name);
const quotaNum = Number(quota);
const res = await keyService.create({
name: name || "未命名",
quota: Number.isFinite(quotaNum) ? quotaNum : 0,
});
setNewKey(res.key);
setName("");
await fetchKeys();
} catch (err: any) {
setError(err.message);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
if (deleteTarget === null) return;
setDeleting(true);
try {
await keyService.remove(deleteTarget);
setDeleteTarget(null);
await fetchKeys();
} catch (err: any) {
setError(err.message);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setDeleteTarget(null);
} finally {
setDeleting(false);
@@ -71,11 +87,10 @@ export default function Keys() {
</div>
)}
{/* Create Key */}
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Key </label>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Key </label>
<input
type="text"
value={name}
@@ -84,6 +99,17 @@ export default function Keys() {
className="w-full px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none"
/>
</div>
<div className="w-40">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"> USD (0=)</label>
<input
type="number"
value={quota}
onChange={(e) => setQuota(e.target.value)}
min="0"
step="0.01"
className="w-full px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none"
/>
</div>
<button
onClick={handleCreate}
disabled={loading}
@@ -94,10 +120,9 @@ export default function Keys() {
</div>
</div>
{/* Newly created key */}
{newKey && (
<div className="mb-4 p-4 rounded-xl bg-green-500/10 border border-green-500/30">
<p className="text-sm text-green-400 mb-2">Key Key </p>
<p className="text-sm text-green-400 mb-2">Key </p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm text-gray-900 dark:text-white bg-superdream-bg px-3 py-2 rounded-lg break-all">
{newKey}
@@ -112,13 +137,15 @@ export default function Keys() {
</div>
)}
{/* Key list */}
<div className="bg-superdream-panel rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium">Key</th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"> (USD)</th>
<th className="text-left px-4 py-3 font-medium">使</th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"></th>
</tr>
@@ -126,7 +153,7 @@ export default function Keys() {
<tbody>
{keys.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
Key
</td>
</tr>
@@ -134,8 +161,21 @@ export default function Keys() {
keys.map((k) => (
<tr key={k.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 text-gray-900 dark:text-white">{k.name || "-"}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 font-mono">
sk-sd-{k.key_prefix}...{k.key_suffix}
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 font-mono">{maskKey(k.key)}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
k.status === "active"
? "bg-green-500/10 text-green-400"
: "bg-gray-500/10 text-gray-400"
}`}>
{k.status}
</span>
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
{formatQuota(k.quota_used, k.quota)}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : "-"}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(k.created_at).toLocaleString()}
@@ -155,8 +195,7 @@ export default function Keys() {
</table>
</div>
{/* Delete confirm modal */}
{deleteTarget && (
{deleteTarget !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60"

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from "react";
import { usageService, UsageLogItem } from "../../services/usageService";
import { usageService, UsageLog } from "../../services/usageService";
export default function Logs() {
const [logs, setLogs] = useState<UsageLogItem[]>([]);
const [logs, setLogs] = useState<UsageLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [model, setModel] = useState("");
const [loading, setLoading] = useState(false);
@@ -13,12 +14,13 @@ export default function Logs() {
try {
const data = await usageService.logs({
page,
size: pageSize,
page_size: pageSize,
model: model || undefined,
});
setLogs(data);
setLogs(data.items);
setTotal(data.total);
} catch {
// silently fail
setLogs([]);
} finally {
setLoading(false);
}
@@ -29,15 +31,16 @@ export default function Logs() {
}, [page]);
const handleFilter = () => {
setPage(1);
fetchLogs();
if (page !== 1) setPage(1);
else fetchLogs();
};
const pages = Math.max(1, Math.ceil(total / pageSize));
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
{/* Filters */}
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
<div className="flex gap-3 items-end">
<div>
@@ -59,53 +62,44 @@ export default function Logs() {
</div>
</div>
{/* Table */}
<div className="bg-superdream-panel rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium">Prompt</th>
<th className="text-right px-4 py-3 font-medium">Completion</th>
<th className="text-right px-4 py-3 font-medium">Total</th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium">Input</th>
<th className="text-right px-4 py-3 font-medium">Output</th>
<th className="text-right px-4 py-3 font-medium">Cache </th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"> ms</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
...
</td>
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">...</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
</td>
<td colSpan={8} className="px-4 py-8 text-center text-gray-500"></td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
{new Date(log.request_time).toLocaleString()}
{new Date(log.created_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-white">{log.model}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.prompt_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.completion_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-900 dark:text-white text-right font-medium">{log.total_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-superdream-accent text-right">¥{log.cost}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs ${
log.status === "success"
? "bg-green-500/10 text-green-400"
: "bg-red-500/10 text-red-400"
}`}>
{log.status}
</span>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{log.request_type}{log.stream ? " · stream" : ""}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.input_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.output_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 text-right">{log.cache_read_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-superdream-accent text-right">${log.total_cost.toFixed(6)}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 text-right">{log.duration_ms ?? "-"}</td>
</tr>
))
)}
@@ -113,7 +107,6 @@ export default function Logs() {
</table>
</div>
{/* Pagination */}
<div className="flex justify-between items-center mt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
@@ -122,10 +115,12 @@ export default function Logs() {
>
</button>
<span className="text-sm text-gray-600 dark:text-gray-400"> {page} </span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{page} / {pages} {total}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={logs.length < pageSize}
disabled={page >= pages}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-200 dark:hover:bg-gray-800 transition disabled:opacity-30"
>

View File

@@ -1,42 +1,76 @@
import { useState, useEffect } from "react";
import { walletService } from "../../services/walletService";
import { authService, UserInfo } from "../../services/authService";
import { keyService } from "../../services/keyService";
import { usageService, UsageSummary } from "../../services/usageService";
import { usageService, UsageStats } from "../../services/usageService";
export default function Overview() {
const [balance, setBalance] = useState("0");
const [user, setUser] = useState<UserInfo | null>(null);
const [keyCount, setKeyCount] = useState(0);
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [today, setToday] = useState<UsageStats | null>(null);
const [month, setMonth] = useState<UsageStats | null>(null);
useEffect(() => {
walletService.getBalance().then((b) => setBalance(b.balance)).catch(() => {});
keyService.list().then((keys) => setKeyCount(keys.length)).catch(() => {});
usageService.summary().then(setSummary).catch(() => {});
authService.me().then(setUser).catch(() => {});
keyService.list({ page_size: 1 }).then((p) => setKeyCount(p.total)).catch(() => {});
usageService.stats({ period: "today" }).then(setToday).catch(() => {});
usageService.stats({ period: "month" }).then(setMonth).catch(() => {});
}, []);
const todayTokens = today ? (today.total_input_tokens + today.total_output_tokens) : 0;
const todayCost = today?.total_cost ?? 0;
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<div className="grid grid-cols-4 gap-4">
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
${(user?.balance ?? 0).toFixed(4)}
</p>
</div>
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"> Key</p>
<p className="text-gray-600 dark:text-gray-400 text-sm">API Key </p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{keyCount}</p>
</div>
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-gray-600 dark:text-gray-400 text-sm"> Tokens</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
{summary ? `${summary.today_tokens.toLocaleString()} tokens` : "0"}
{todayTokens.toLocaleString()}
</p>
</div>
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">¥{summary?.today_cost ?? "0"}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
${todayCost.toFixed(4)}
</p>
</div>
</div>
{month && (
<div className="mt-6 bg-superdream-panel rounded-xl p-4">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3"></h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-gray-500"></p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{month.total_requests?.toLocaleString() ?? 0}
</p>
</div>
<div>
<p className="text-gray-500">Tokens</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{(month.total_input_tokens + month.total_output_tokens).toLocaleString()}
</p>
</div>
<div>
<p className="text-gray-500"></p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
${month.total_cost?.toFixed(4) ?? "0"}
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -4,42 +4,45 @@ import {
BarChart, Bar, Cell,
} from "recharts";
import {
usageService, UsageSummary, DailyUsage, ModelUsage,
usageService, UsageStats, DashboardTrendPoint, DashboardModelStat,
} from "../../services/usageService";
const COLORS = ["#8B5CF6", "#a78bfa", "#6366f1", "#818cf8", "#c084fc", "#7c3aed"];
export default function Usage() {
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [daily, setDaily] = useState<DailyUsage[]>([]);
const [byModel, setByModel] = useState<ModelUsage[]>([]);
const [today, setToday] = useState<UsageStats | null>(null);
const [month, setMonth] = useState<UsageStats | null>(null);
const [trend, setTrend] = useState<DashboardTrendPoint[]>([]);
const [models, setModels] = useState<DashboardModelStat[]>([]);
useEffect(() => {
usageService.summary().then(setSummary).catch(() => {});
usageService.daily().then(setDaily).catch(() => {});
usageService.byModel().then(setByModel).catch(() => {});
usageService.stats({ period: "today" }).then(setToday).catch(() => {});
usageService.stats({ period: "month" }).then(setMonth).catch(() => {});
usageService.dashboardTrend({ granularity: "day" }).then((r) => setTrend(r.trend)).catch(() => {});
usageService.dashboardModels().then((r) => setModels(r.models)).catch(() => {});
}, []);
const todayTokens = today ? today.total_input_tokens + today.total_output_tokens : 0;
const monthTokens = month ? month.total_input_tokens + month.total_output_tokens : 0;
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
{/* Summary cards */}
<div className="grid grid-cols-4 gap-4 mb-6">
<Card label="今日 Tokens" value={summary?.today_tokens?.toLocaleString() ?? "0"} />
<Card label="今日消耗" value={`¥${summary?.today_cost ?? "0"}`} />
<Card label="本月 Tokens" value={summary?.month_tokens?.toLocaleString() ?? "0"} />
<Card label="本月消耗" value={`¥${summary?.month_cost ?? "0"}`} />
<Card label="今日 Tokens" value={todayTokens.toLocaleString()} />
<Card label="今日消耗" value={`$${(today?.total_cost ?? 0).toFixed(4)}`} />
<Card label="本月 Tokens" value={monthTokens.toLocaleString()} />
<Card label="本月消耗" value={`$${(month?.total_cost ?? 0).toFixed(4)}`} />
</div>
{/* Daily chart */}
<div className="bg-superdream-panel rounded-xl p-4 mb-6">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3"> 30 </h3>
{daily.length === 0 ? (
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3"></h3>
{trend.length === 0 ? (
<p className="text-gray-500 text-sm py-8 text-center"></p>
) : (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={daily}>
<LineChart data={trend}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis
dataKey="date"
@@ -52,40 +55,29 @@ export default function Usage() {
labelStyle={{ color: "#fff" }}
itemStyle={{ color: "#a78bfa" }}
/>
<Line
type="monotone"
dataKey="total_tokens"
name="Tokens"
stroke="#8B5CF6"
strokeWidth={2}
dot={false}
/>
<Line type="monotone" dataKey="requests" name="请求数" stroke="#8B5CF6" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* By model chart */}
<div className="bg-superdream-panel rounded-xl p-4">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3"></h3>
{byModel.length === 0 ? (
{models.length === 0 ? (
<p className="text-gray-500 text-sm py-8 text-center"></p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={byModel}>
<BarChart data={models}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis
dataKey="model"
tick={{ fill: "#9ca3af", fontSize: 12 }}
/>
<XAxis dataKey="model" tick={{ fill: "#9ca3af", fontSize: 12 }} />
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
<Tooltip
contentStyle={{ background: "#1a1a2e", border: "1px solid #333", borderRadius: 8 }}
labelStyle={{ color: "#fff" }}
itemStyle={{ color: "#a78bfa" }}
/>
<Bar dataKey="total_tokens" name="Tokens" radius={[4, 4, 0, 0]}>
{byModel.map((_, i) => (
<Bar dataKey="requests" name="请求数" radius={[4, 4, 0, 0]}>
{models.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>

View File

@@ -1,138 +1,26 @@
import { useState, useEffect } from "react";
import { walletService, TransactionInfo } from "../../services/walletService";
const TYPE_LABELS: Record<string, string> = {
topup: "充值",
consume: "消费",
refund: "退款",
};
import { authService, UserInfo } from "../../services/authService";
export default function Wallet() {
const [balance, setBalance] = useState("0");
const [code, setCode] = useState("");
const [transactions, setTransactions] = useState<TransactionInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const fetchData = async () => {
try {
const [b, txns] = await Promise.all([
walletService.getBalance(),
walletService.transactions(),
]);
setBalance(b.balance);
setTransactions(txns);
} catch (err: any) {
setError(err.message);
}
};
const [user, setUser] = useState<UserInfo | null>(null);
useEffect(() => {
fetchData();
authService.me().then(setUser).catch(() => {});
}, []);
const handleRedeem = async () => {
setError("");
setSuccess("");
if (!code.trim()) return;
setLoading(true);
try {
const txn = await walletService.redeem(code.trim());
setSuccess(`充值成功!+¥${txn.amount}`);
setCode("");
await fetchData();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
{/* Balance */}
<div className="bg-superdream-panel rounded-xl p-6 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
<p className="text-gray-600 dark:text-gray-400 text-sm"> (USD)</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">
${(user?.balance ?? 0).toFixed(4)}
</p>
</div>
{/* Redeem */}
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2"></label>
{error && (
<div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-3 p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm">
{success}
</div>
)}
<div className="flex gap-3">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="输入兑换码"
className="flex-1 px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none"
/>
<button
onClick={handleRedeem}
disabled={loading || !code.trim()}
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition disabled:opacity-50"
>
{loading ? "兑换中..." : "兑换"}
</button>
</div>
</div>
{/* Transactions */}
<div className="bg-superdream-panel rounded-xl overflow-hidden">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 px-4 py-3 border-b border-gray-200 dark:border-gray-800">
</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{transactions.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
</td>
</tr>
) : (
transactions.map((txn) => (
<tr key={txn.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 text-gray-900 dark:text-white">
{TYPE_LABELS[txn.type] || txn.type}
</td>
<td className={`px-4 py-3 font-medium ${txn.type === "consume" ? "text-red-400" : "text-green-400"}`}>
{txn.type === "consume" ? "-" : "+"}¥{txn.amount}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">¥{txn.balance_after}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{txn.reference_id || "-"}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(txn.created_at).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="bg-superdream-panel rounded-xl p-4 text-sm text-gray-600 dark:text-gray-400">
sub2api <code>/api/v1/redeem</code>
</div>
</div>
);

View File

@@ -9,9 +9,10 @@ export interface TokenResponse {
export interface UserInfo {
id: string;
email: string;
balance: string;
status: string;
created_at: string;
sub2api_user_id: number | null;
balance: number;
}
export const authService = {

View File

@@ -19,7 +19,7 @@ async function request<T>(
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `HTTP ${res.status}: ${res.statusText}`);
throw new Error(body.detail || body.message || `HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
}
@@ -31,6 +31,11 @@ export const httpClient = {
method: "POST",
body: JSON.stringify(body),
}),
put: <T>(endpoint: string, body: unknown) =>
request<T>(endpoint, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: "DELETE" }),
};

View File

@@ -1,25 +1,100 @@
import { httpClient } from "./httpClient";
export interface ApiKeyInfo {
id: string;
// Matches sub2api dto.APIKey 1:1 (see docs/sub2api_api.md §3.1)
export interface ApiKey {
id: number;
user_id: number;
key: string;
name: string;
key_prefix: string;
key_suffix: string;
status: string;
group_id: number | null;
status: "active" | "inactive";
ip_whitelist: string[];
ip_blacklist: string[];
last_used_at: string | null;
quota: number;
quota_used: number;
expires_at: string | null;
rate_limit_5h: number;
rate_limit_1d: number;
rate_limit_7d: number;
usage_5h: number;
usage_1d: number;
usage_7d: number;
window_5h_start: string | null;
window_1d_start: string | null;
window_7d_start: string | null;
reset_5h_at?: string | null;
reset_1d_at?: string | null;
reset_7d_at?: string | null;
created_at: string;
updated_at: string;
group?: Group;
}
export interface ApiKeyCreated {
id: string;
export interface Group {
id: number;
name: string;
key: string;
key_prefix: string;
key_suffix: string;
created_at: string;
description: string;
platform: string;
rate_multiplier: number;
status: string;
}
export interface Paginated<T> {
items: T[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface CreateKeyPayload {
name: string;
group_id?: number | null;
custom_key?: string | null;
ip_whitelist?: string[];
ip_blacklist?: string[];
quota?: number;
expires_in_days?: number | null;
rate_limit_5h?: number;
rate_limit_1d?: number;
rate_limit_7d?: number;
}
export interface UpdateKeyPayload {
name?: string;
group_id?: number | null;
status?: "active" | "inactive";
ip_whitelist?: string[];
ip_blacklist?: string[];
quota?: number;
expires_at?: string | null;
reset_quota?: boolean;
rate_limit_5h?: number;
rate_limit_1d?: number;
rate_limit_7d?: number;
reset_rate_limit_usage?: boolean;
}
export const keyService = {
list: () => httpClient.get<ApiKeyInfo[]>("/keys"),
create: (name: string) => httpClient.post<ApiKeyCreated>("/keys", { name }),
remove: (keyId: string) => httpClient.delete<{ message: string }>(`/keys/${keyId}`),
list: (params: { page?: number; page_size?: number; sort_by?: string; sort_order?: string } = {}) => {
const sp = new URLSearchParams();
if (params.page) sp.set("page", String(params.page));
if (params.page_size) sp.set("page_size", String(params.page_size));
if (params.sort_by) sp.set("sort_by", params.sort_by);
if (params.sort_order) sp.set("sort_order", params.sort_order);
const qs = sp.toString();
return httpClient.get<Paginated<ApiKey>>(`/keys${qs ? `?${qs}` : ""}`);
},
get: (keyId: number) => httpClient.get<ApiKey>(`/keys/${keyId}`),
create: (payload: CreateKeyPayload) => httpClient.post<ApiKey>("/keys", payload),
update: (keyId: number, payload: UpdateKeyPayload) =>
httpClient.put<ApiKey>(`/keys/${keyId}`, payload),
remove: (keyId: number) => httpClient.delete<{ message: string }>(`/keys/${keyId}`),
availableGroups: () => httpClient.get<Group[]>("/keys/meta/available-groups"),
};

View File

@@ -1,80 +1,143 @@
import { httpClient } from "./httpClient";
import type { Paginated } from "./keyService";
export interface UsageSummary {
today_tokens: number;
today_cost: string;
month_tokens: number;
month_cost: string;
total_requests: number;
}
export interface DailyUsage {
date: string;
total_tokens: number;
cost: string;
requests: number;
}
export interface ModelUsage {
model: string;
total_tokens: number;
cost: string;
requests: number;
}
export interface KeyUsage {
key_id: string;
key_name: string;
key_prefix: string;
key_suffix: string;
total_tokens: number;
cost: string;
requests: number;
}
export interface UsageLogItem {
// Matches sub2api dto.UsageLog 1:1 (see docs/sub2api_api.md §4.1)
export interface UsageLog {
id: number;
key_id: string;
user_id: number;
api_key_id: number;
account_id: number;
request_id: string;
model: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
cost: string;
request_time: string;
status: string;
service_tier?: string | null;
reasoning_effort?: string | null;
inbound_endpoint?: string | null;
upstream_endpoint?: string | null;
group_id: number | null;
subscription_id: number | null;
input_tokens: number;
output_tokens: number;
cache_creation_tokens: number;
cache_read_tokens: number;
input_cost: number;
output_cost: number;
cache_creation_cost: number;
cache_read_cost: number;
total_cost: number;
actual_cost: number;
rate_multiplier: number;
billing_type: number;
request_type: string;
stream: boolean;
duration_ms: number | null;
first_token_ms: number | null;
image_count: number;
image_size: string | null;
created_at: string;
}
// sub2api service.UsageStats shape (summed totals for a period)
export interface UsageStats {
total_requests: number;
total_input_tokens: number;
total_output_tokens: number;
total_cache_creation_tokens: number;
total_cache_read_tokens: number;
total_cost: number;
total_actual_cost: number;
[key: string]: unknown;
}
export interface DashboardStats {
balance?: number;
today?: UsageStats;
month?: UsageStats;
total_requests?: number;
active_keys?: number;
[key: string]: unknown;
}
export interface DashboardTrendPoint {
date: string;
requests: number;
input_tokens: number;
output_tokens: number;
total_cost: number;
[key: string]: unknown;
}
export interface DashboardModelStat {
model: string;
requests: number;
input_tokens: number;
output_tokens: number;
total_cost: number;
[key: string]: unknown;
}
export const usageService = {
summary: () => httpClient.get<UsageSummary>("/usage/summary"),
daily: (start?: string, end?: string) => {
const params = new URLSearchParams();
if (start) params.set("start", start);
if (end) params.set("end", end);
const qs = params.toString();
return httpClient.get<DailyUsage[]>(`/usage/daily${qs ? `?${qs}` : ""}`);
},
byModel: () => httpClient.get<ModelUsage[]>("/usage/by-model"),
byKey: () => httpClient.get<KeyUsage[]>("/usage/by-key"),
logs: (params: {
logs: (
params: {
page?: number;
size?: number;
page_size?: number;
api_key_id?: number;
model?: string;
key_id?: string;
start?: string;
end?: string;
} = {}) => {
request_type?: string;
stream?: boolean;
start_date?: string;
end_date?: string;
timezone?: string;
} = {},
) => {
const sp = new URLSearchParams();
if (params.page) sp.set("page", String(params.page));
if (params.size) sp.set("size", String(params.size));
if (params.model) sp.set("model", params.model);
if (params.key_id) sp.set("key_id", params.key_id);
if (params.start) sp.set("start", params.start);
if (params.end) sp.set("end", params.end);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const qs = sp.toString();
return httpClient.get<UsageLogItem[]>(`/usage/logs${qs ? `?${qs}` : ""}`);
return httpClient.get<Paginated<UsageLog>>(`/usage${qs ? `?${qs}` : ""}`);
},
stats: (
params: {
period?: "today" | "week" | "month";
start_date?: string;
end_date?: string;
api_key_id?: number;
timezone?: string;
} = {},
) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const qs = sp.toString();
return httpClient.get<UsageStats>(`/usage/stats${qs ? `?${qs}` : ""}`);
},
dashboardStats: () => httpClient.get<DashboardStats>("/usage/dashboard/stats"),
dashboardTrend: (params: { granularity?: string; start_date?: string; end_date?: string } = {}) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const qs = sp.toString();
return httpClient.get<{ trend: DashboardTrendPoint[]; start_date: string; end_date: string; granularity: string }>(
`/usage/dashboard/trend${qs ? `?${qs}` : ""}`,
);
},
dashboardModels: (params: { start_date?: string; end_date?: string } = {}) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const qs = sp.toString();
return httpClient.get<{ models: DashboardModelStat[]; start_date: string; end_date: string }>(
`/usage/dashboard/models${qs ? `?${qs}` : ""}`,
);
},
dashboardAPIKeysUsage: (api_key_ids: number[]) =>
httpClient.post<{ stats: Record<string, UsageStats> }>("/usage/dashboard/api-keys-usage", { api_key_ids }),
};

View File

@@ -1,21 +0,0 @@
import { httpClient } from "./httpClient";
export interface BalanceInfo {
balance: string;
}
export interface TransactionInfo {
id: string;
type: string;
amount: string;
balance_after: string;
reference_id: string;
created_at: string;
}
export const walletService = {
getBalance: () => httpClient.get<BalanceInfo>("/wallet/balance"),
redeem: (code: string) => httpClient.post<TransactionInfo>("/wallet/redeem", { code }),
transactions: (page = 1, size = 20) =>
httpClient.get<TransactionInfo[]>(`/wallet/transactions?page=${page}&size=${size}`),
};

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/index.tsx","./src/types.ts","./src/components/authguard.tsx","./src/components/header.tsx","./src/components/statusbar.tsx","./src/hooks/usefetch.ts","./src/pages/docs.tsx","./src/pages/forgotpassword.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/pricing.tsx","./src/pages/register.tsx","./src/pages/resetpassword.tsx","./src/pages/dashboard/keys.tsx","./src/pages/dashboard/layout.tsx","./src/pages/dashboard/logs.tsx","./src/pages/dashboard/overview.tsx","./src/pages/dashboard/usage.tsx","./src/pages/dashboard/wallet.tsx","./src/services/authservice.ts","./src/services/exampleservice.ts","./src/services/httpclient.ts","./src/services/keyservice.ts","./src/services/usageservice.ts","./src/services/walletservice.ts","./src/stores/authstore.ts","./src/stores/themestore.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/index.tsx","./src/types.ts","./src/components/authguard.tsx","./src/components/header.tsx","./src/components/statusbar.tsx","./src/hooks/usefetch.ts","./src/pages/docs.tsx","./src/pages/forgotpassword.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/pricing.tsx","./src/pages/register.tsx","./src/pages/resetpassword.tsx","./src/pages/dashboard/keys.tsx","./src/pages/dashboard/layout.tsx","./src/pages/dashboard/logs.tsx","./src/pages/dashboard/overview.tsx","./src/pages/dashboard/usage.tsx","./src/pages/dashboard/wallet.tsx","./src/services/authservice.ts","./src/services/exampleservice.ts","./src/services/httpclient.ts","./src/services/keyservice.ts","./src/services/usageservice.ts","./src/stores/authstore.ts","./src/stores/themestore.ts"],"version":"5.8.3"}