codex-lb 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +5 -0
- app/cli.py +24 -0
- app/core/__init__.py +0 -0
- app/core/auth/__init__.py +96 -0
- app/core/auth/models.py +49 -0
- app/core/auth/refresh.py +144 -0
- app/core/balancer/__init__.py +19 -0
- app/core/balancer/logic.py +140 -0
- app/core/balancer/types.py +9 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/http.py +39 -0
- app/core/clients/oauth.py +340 -0
- app/core/clients/proxy.py +265 -0
- app/core/clients/usage.py +143 -0
- app/core/config/__init__.py +0 -0
- app/core/config/settings.py +69 -0
- app/core/crypto.py +37 -0
- app/core/errors.py +73 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/models.py +122 -0
- app/core/openai/parsing.py +55 -0
- app/core/openai/requests.py +59 -0
- app/core/types.py +4 -0
- app/core/usage/__init__.py +185 -0
- app/core/usage/logs.py +57 -0
- app/core/usage/models.py +35 -0
- app/core/usage/pricing.py +172 -0
- app/core/usage/types.py +95 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/request_id.py +30 -0
- app/core/utils/retry.py +16 -0
- app/core/utils/sse.py +13 -0
- app/core/utils/time.py +19 -0
- app/db/__init__.py +0 -0
- app/db/models.py +82 -0
- app/db/session.py +44 -0
- app/dependencies.py +123 -0
- app/main.py +124 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +81 -0
- app/modules/accounts/repository.py +80 -0
- app/modules/accounts/schemas.py +66 -0
- app/modules/accounts/service.py +211 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +10 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +57 -0
- app/modules/oauth/schemas.py +32 -0
- app/modules/oauth/service.py +356 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/api.py +76 -0
- app/modules/proxy/auth_manager.py +51 -0
- app/modules/proxy/load_balancer.py +208 -0
- app/modules/proxy/schemas.py +85 -0
- app/modules/proxy/service.py +707 -0
- app/modules/proxy/types.py +37 -0
- app/modules/proxy/usage_updater.py +147 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +31 -0
- app/modules/request_logs/repository.py +86 -0
- app/modules/request_logs/schemas.py +25 -0
- app/modules/request_logs/service.py +77 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +8 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/api.py +31 -0
- app/modules/usage/repository.py +113 -0
- app/modules/usage/schemas.py +62 -0
- app/modules/usage/service.py +246 -0
- app/static/7.css +1336 -0
- app/static/index.css +543 -0
- app/static/index.html +457 -0
- app/static/index.js +1898 -0
- codex_lb-0.1.2.dist-info/METADATA +108 -0
- codex_lb-0.1.2.dist-info/RECORD +80 -0
- codex_lb-0.1.2.dist-info/WHEEL +4 -0
- codex_lb-0.1.2.dist-info/entry_points.txt +2 -0
- codex_lb-0.1.2.dist-info/licenses/LICENSE +21 -0
app/db/models.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, LargeBinary, String, Text, func
|
|
7
|
+
from sqlalchemy import Enum as SqlEnum
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Base(DeclarativeBase):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AccountStatus(str, Enum):
|
|
16
|
+
ACTIVE = "active"
|
|
17
|
+
RATE_LIMITED = "rate_limited"
|
|
18
|
+
QUOTA_EXCEEDED = "quota_exceeded"
|
|
19
|
+
PAUSED = "paused"
|
|
20
|
+
DEACTIVATED = "deactivated"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Account(Base):
|
|
24
|
+
__tablename__ = "accounts"
|
|
25
|
+
|
|
26
|
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
27
|
+
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
28
|
+
plan_type: Mapped[str] = mapped_column(String, nullable=False)
|
|
29
|
+
|
|
30
|
+
access_token_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
|
31
|
+
refresh_token_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
|
32
|
+
id_token_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
|
33
|
+
|
|
34
|
+
last_refresh: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
35
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
|
36
|
+
|
|
37
|
+
status: Mapped[AccountStatus] = mapped_column(
|
|
38
|
+
SqlEnum(AccountStatus, name="account_status", validate_strings=True),
|
|
39
|
+
default=AccountStatus.ACTIVE,
|
|
40
|
+
nullable=False,
|
|
41
|
+
)
|
|
42
|
+
deactivation_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UsageHistory(Base):
|
|
46
|
+
__tablename__ = "usage_history"
|
|
47
|
+
|
|
48
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
49
|
+
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id"), nullable=False)
|
|
50
|
+
recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
|
51
|
+
window: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
52
|
+
used_percent: Mapped[float] = mapped_column(Float, nullable=False)
|
|
53
|
+
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
54
|
+
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
55
|
+
reset_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
56
|
+
window_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
57
|
+
credits_has: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
58
|
+
credits_unlimited: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
59
|
+
credits_balance: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RequestLog(Base):
|
|
63
|
+
__tablename__ = "request_logs"
|
|
64
|
+
|
|
65
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
66
|
+
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id"), nullable=False)
|
|
67
|
+
request_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
68
|
+
requested_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
|
69
|
+
model: Mapped[str] = mapped_column(String, nullable=False)
|
|
70
|
+
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
71
|
+
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
72
|
+
cached_input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
73
|
+
reasoning_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
74
|
+
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
75
|
+
status: Mapped[str] = mapped_column(String, nullable=False)
|
|
76
|
+
error_code: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
77
|
+
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Index("idx_usage_recorded_at", UsageHistory.recorded_at)
|
|
81
|
+
Index("idx_usage_account_time", UsageHistory.account_id, UsageHistory.recorded_at)
|
|
82
|
+
Index("idx_logs_account_time", RequestLog.account_id, RequestLog.requested_at)
|
app/db/session.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import AsyncIterator
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
7
|
+
|
|
8
|
+
from app.core.config.settings import get_settings
|
|
9
|
+
|
|
10
|
+
DATABASE_URL = get_settings().database_url
|
|
11
|
+
|
|
12
|
+
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
13
|
+
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ensure_sqlite_dir(url: str) -> None:
|
|
17
|
+
prefix = "sqlite+aiosqlite:///"
|
|
18
|
+
if not url.startswith(prefix):
|
|
19
|
+
return
|
|
20
|
+
path = url[len(prefix) :]
|
|
21
|
+
if path == ":memory:":
|
|
22
|
+
return
|
|
23
|
+
Path(path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def get_session() -> AsyncIterator[AsyncSession]:
|
|
27
|
+
async with SessionLocal() as session:
|
|
28
|
+
try:
|
|
29
|
+
yield session
|
|
30
|
+
except Exception:
|
|
31
|
+
await session.rollback()
|
|
32
|
+
raise
|
|
33
|
+
finally:
|
|
34
|
+
if session.in_transaction():
|
|
35
|
+
await session.rollback()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def init_db() -> None:
|
|
39
|
+
from app.db.models import Base
|
|
40
|
+
|
|
41
|
+
_ensure_sqlite_dir(DATABASE_URL)
|
|
42
|
+
|
|
43
|
+
async with engine.begin() as conn:
|
|
44
|
+
await conn.run_sync(Base.metadata.create_all)
|
app/dependencies.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from app.db.session import SessionLocal, get_session
|
|
11
|
+
from app.modules.accounts.repository import AccountsRepository
|
|
12
|
+
from app.modules.accounts.service import AccountsService
|
|
13
|
+
from app.modules.oauth.service import OauthService
|
|
14
|
+
from app.modules.proxy.service import ProxyService
|
|
15
|
+
from app.modules.request_logs.repository import RequestLogsRepository
|
|
16
|
+
from app.modules.request_logs.service import RequestLogsService
|
|
17
|
+
from app.modules.usage.repository import UsageRepository
|
|
18
|
+
from app.modules.usage.service import UsageService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class AccountsContext:
|
|
23
|
+
session: AsyncSession
|
|
24
|
+
repository: AccountsRepository
|
|
25
|
+
usage_repository: UsageRepository
|
|
26
|
+
request_logs_repository: RequestLogsRepository
|
|
27
|
+
service: AccountsService
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class UsageContext:
|
|
32
|
+
session: AsyncSession
|
|
33
|
+
usage_repository: UsageRepository
|
|
34
|
+
request_logs_repository: RequestLogsRepository
|
|
35
|
+
accounts_repository: AccountsRepository
|
|
36
|
+
service: UsageService
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class OauthContext:
|
|
41
|
+
service: OauthService
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class ProxyContext:
|
|
46
|
+
service: ProxyService
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class RequestLogsContext:
|
|
51
|
+
session: AsyncSession
|
|
52
|
+
repository: RequestLogsRepository
|
|
53
|
+
service: RequestLogsService
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_accounts_context(
|
|
57
|
+
session: AsyncSession = Depends(get_session),
|
|
58
|
+
) -> AccountsContext:
|
|
59
|
+
repository = AccountsRepository(session)
|
|
60
|
+
usage_repository = UsageRepository(session)
|
|
61
|
+
request_logs_repository = RequestLogsRepository(session)
|
|
62
|
+
service = AccountsService(repository, usage_repository, request_logs_repository)
|
|
63
|
+
return AccountsContext(
|
|
64
|
+
session=session,
|
|
65
|
+
repository=repository,
|
|
66
|
+
usage_repository=usage_repository,
|
|
67
|
+
request_logs_repository=request_logs_repository,
|
|
68
|
+
service=service,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_usage_context(
|
|
73
|
+
session: AsyncSession = Depends(get_session),
|
|
74
|
+
) -> UsageContext:
|
|
75
|
+
usage_repository = UsageRepository(session)
|
|
76
|
+
request_logs_repository = RequestLogsRepository(session)
|
|
77
|
+
accounts_repository = AccountsRepository(session)
|
|
78
|
+
service = UsageService(usage_repository, request_logs_repository, accounts_repository)
|
|
79
|
+
return UsageContext(
|
|
80
|
+
session=session,
|
|
81
|
+
usage_repository=usage_repository,
|
|
82
|
+
request_logs_repository=request_logs_repository,
|
|
83
|
+
accounts_repository=accounts_repository,
|
|
84
|
+
service=service,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@asynccontextmanager
|
|
89
|
+
async def _accounts_repo_context() -> AsyncIterator[AccountsRepository]:
|
|
90
|
+
async with SessionLocal() as session:
|
|
91
|
+
try:
|
|
92
|
+
yield AccountsRepository(session)
|
|
93
|
+
except Exception:
|
|
94
|
+
await session.rollback()
|
|
95
|
+
raise
|
|
96
|
+
finally:
|
|
97
|
+
if session.in_transaction():
|
|
98
|
+
await session.rollback()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_oauth_context(
|
|
102
|
+
session: AsyncSession = Depends(get_session),
|
|
103
|
+
) -> OauthContext:
|
|
104
|
+
accounts_repository = AccountsRepository(session)
|
|
105
|
+
return OauthContext(service=OauthService(accounts_repository, repo_factory=_accounts_repo_context))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_proxy_context(
|
|
109
|
+
session: AsyncSession = Depends(get_session),
|
|
110
|
+
) -> ProxyContext:
|
|
111
|
+
accounts_repository = AccountsRepository(session)
|
|
112
|
+
usage_repository = UsageRepository(session)
|
|
113
|
+
request_logs_repository = RequestLogsRepository(session)
|
|
114
|
+
service = ProxyService(accounts_repository, usage_repository, request_logs_repository)
|
|
115
|
+
return ProxyContext(service=service)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_request_logs_context(
|
|
119
|
+
session: AsyncSession = Depends(get_session),
|
|
120
|
+
) -> RequestLogsContext:
|
|
121
|
+
repository = RequestLogsRepository(session)
|
|
122
|
+
service = RequestLogsService(repository)
|
|
123
|
+
return RequestLogsContext(session=session, repository=repository, service=service)
|
app/main.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Request
|
|
9
|
+
from fastapi.exception_handlers import (
|
|
10
|
+
http_exception_handler,
|
|
11
|
+
request_validation_exception_handler,
|
|
12
|
+
)
|
|
13
|
+
from fastapi.exceptions import RequestValidationError
|
|
14
|
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
17
|
+
|
|
18
|
+
from app.core.clients.http import close_http_client, init_http_client
|
|
19
|
+
from app.core.errors import dashboard_error
|
|
20
|
+
from app.core.utils.request_id import get_request_id, reset_request_id, set_request_id
|
|
21
|
+
from app.db.session import init_db
|
|
22
|
+
from app.modules.accounts import api as accounts_api
|
|
23
|
+
from app.modules.health import api as health_api
|
|
24
|
+
from app.modules.oauth import api as oauth_api
|
|
25
|
+
from app.modules.proxy import api as proxy_api
|
|
26
|
+
from app.modules.request_logs import api as request_logs_api
|
|
27
|
+
from app.modules.usage import api as usage_api
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(_: FastAPI):
|
|
34
|
+
await init_db()
|
|
35
|
+
await init_http_client()
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
finally:
|
|
40
|
+
await close_http_client()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_app() -> FastAPI:
|
|
44
|
+
app = FastAPI(title="codex-lb", version="0.1.0", lifespan=lifespan)
|
|
45
|
+
|
|
46
|
+
@app.middleware("http")
|
|
47
|
+
async def request_id_middleware(request: Request, call_next) -> JSONResponse:
|
|
48
|
+
inbound_request_id = request.headers.get("x-request-id") or request.headers.get("request-id")
|
|
49
|
+
request_id = inbound_request_id or str(uuid4())
|
|
50
|
+
token = set_request_id(request_id)
|
|
51
|
+
try:
|
|
52
|
+
response = await call_next(request)
|
|
53
|
+
except Exception:
|
|
54
|
+
reset_request_id(token)
|
|
55
|
+
raise
|
|
56
|
+
response.headers.setdefault("x-request-id", request_id)
|
|
57
|
+
return response
|
|
58
|
+
|
|
59
|
+
@app.middleware("http")
|
|
60
|
+
async def api_unhandled_error_middleware(request: Request, call_next) -> JSONResponse:
|
|
61
|
+
try:
|
|
62
|
+
return await call_next(request)
|
|
63
|
+
except Exception:
|
|
64
|
+
if request.url.path.startswith("/api/"):
|
|
65
|
+
logger.exception(
|
|
66
|
+
"Unhandled API error request_id=%s",
|
|
67
|
+
get_request_id(),
|
|
68
|
+
)
|
|
69
|
+
return JSONResponse(
|
|
70
|
+
status_code=500,
|
|
71
|
+
content=dashboard_error("internal_error", "Unexpected error"),
|
|
72
|
+
)
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
@app.exception_handler(RequestValidationError)
|
|
76
|
+
async def _validation_error_handler(
|
|
77
|
+
request: Request,
|
|
78
|
+
exc: RequestValidationError,
|
|
79
|
+
) -> JSONResponse:
|
|
80
|
+
if request.url.path.startswith("/api/"):
|
|
81
|
+
return JSONResponse(
|
|
82
|
+
status_code=422,
|
|
83
|
+
content=dashboard_error("validation_error", "Invalid request payload"),
|
|
84
|
+
)
|
|
85
|
+
return await request_validation_exception_handler(request, exc)
|
|
86
|
+
|
|
87
|
+
@app.exception_handler(StarletteHTTPException)
|
|
88
|
+
async def _http_error_handler(
|
|
89
|
+
request: Request,
|
|
90
|
+
exc: StarletteHTTPException,
|
|
91
|
+
) -> JSONResponse:
|
|
92
|
+
if request.url.path.startswith("/api/"):
|
|
93
|
+
detail = exc.detail if isinstance(exc.detail, str) else "Request failed"
|
|
94
|
+
return JSONResponse(
|
|
95
|
+
status_code=exc.status_code,
|
|
96
|
+
content=dashboard_error(f"http_{exc.status_code}", detail),
|
|
97
|
+
)
|
|
98
|
+
return await http_exception_handler(request, exc)
|
|
99
|
+
|
|
100
|
+
app.include_router(proxy_api.router)
|
|
101
|
+
app.include_router(proxy_api.usage_router)
|
|
102
|
+
app.include_router(accounts_api.router)
|
|
103
|
+
app.include_router(usage_api.router)
|
|
104
|
+
app.include_router(request_logs_api.router)
|
|
105
|
+
app.include_router(oauth_api.router)
|
|
106
|
+
app.include_router(health_api.router)
|
|
107
|
+
|
|
108
|
+
static_dir = Path(__file__).parent / "static"
|
|
109
|
+
index_html = static_dir / "index.html"
|
|
110
|
+
|
|
111
|
+
@app.get("/", include_in_schema=False)
|
|
112
|
+
async def root_redirect():
|
|
113
|
+
return RedirectResponse(url="/dashboard", status_code=302)
|
|
114
|
+
|
|
115
|
+
@app.get("/accounts", include_in_schema=False)
|
|
116
|
+
async def spa_accounts():
|
|
117
|
+
return FileResponse(index_html, media_type="text/html")
|
|
118
|
+
|
|
119
|
+
app.mount("/dashboard", StaticFiles(directory=static_dir, html=True), name="dashboard")
|
|
120
|
+
|
|
121
|
+
return app
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
app = create_app()
|
app/modules/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, File, UploadFile
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
from app.core.errors import dashboard_error
|
|
7
|
+
from app.dependencies import AccountsContext, get_accounts_context
|
|
8
|
+
from app.modules.accounts.schemas import (
|
|
9
|
+
AccountDeleteResponse,
|
|
10
|
+
AccountImportResponse,
|
|
11
|
+
AccountPauseResponse,
|
|
12
|
+
AccountReactivateResponse,
|
|
13
|
+
AccountsResponse,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/api/accounts", tags=["dashboard"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("", response_model=AccountsResponse)
|
|
20
|
+
async def list_accounts(
|
|
21
|
+
context: AccountsContext = Depends(get_accounts_context),
|
|
22
|
+
) -> AccountsResponse:
|
|
23
|
+
accounts = await context.service.list_accounts()
|
|
24
|
+
return AccountsResponse(accounts=accounts)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post("/import", response_model=AccountImportResponse)
|
|
28
|
+
async def import_account(
|
|
29
|
+
auth_json: UploadFile = File(...),
|
|
30
|
+
context: AccountsContext = Depends(get_accounts_context),
|
|
31
|
+
) -> AccountImportResponse | JSONResponse:
|
|
32
|
+
raw = await auth_json.read()
|
|
33
|
+
try:
|
|
34
|
+
return await context.service.import_account(raw)
|
|
35
|
+
except Exception:
|
|
36
|
+
return JSONResponse(
|
|
37
|
+
status_code=400,
|
|
38
|
+
content=dashboard_error("invalid_auth_json", "Invalid auth.json payload"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/{account_id}/reactivate", response_model=AccountReactivateResponse)
|
|
43
|
+
async def reactivate_account(
|
|
44
|
+
account_id: str,
|
|
45
|
+
context: AccountsContext = Depends(get_accounts_context),
|
|
46
|
+
) -> AccountReactivateResponse | JSONResponse:
|
|
47
|
+
success = await context.service.reactivate_account(account_id)
|
|
48
|
+
if not success:
|
|
49
|
+
return JSONResponse(
|
|
50
|
+
status_code=404,
|
|
51
|
+
content=dashboard_error("account_not_found", "Account not found"),
|
|
52
|
+
)
|
|
53
|
+
return AccountReactivateResponse(status="reactivated")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.post("/{account_id}/pause", response_model=AccountPauseResponse)
|
|
57
|
+
async def pause_account(
|
|
58
|
+
account_id: str,
|
|
59
|
+
context: AccountsContext = Depends(get_accounts_context),
|
|
60
|
+
) -> AccountPauseResponse | JSONResponse:
|
|
61
|
+
success = await context.service.pause_account(account_id)
|
|
62
|
+
if not success:
|
|
63
|
+
return JSONResponse(
|
|
64
|
+
status_code=404,
|
|
65
|
+
content=dashboard_error("account_not_found", "Account not found"),
|
|
66
|
+
)
|
|
67
|
+
return AccountPauseResponse(status="paused")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.delete("/{account_id}", response_model=AccountDeleteResponse)
|
|
71
|
+
async def delete_account(
|
|
72
|
+
account_id: str,
|
|
73
|
+
context: AccountsContext = Depends(get_accounts_context),
|
|
74
|
+
) -> AccountDeleteResponse | JSONResponse:
|
|
75
|
+
success = await context.service.delete_account(account_id)
|
|
76
|
+
if not success:
|
|
77
|
+
return JSONResponse(
|
|
78
|
+
status_code=404,
|
|
79
|
+
content=dashboard_error("account_not_found", "Account not found"),
|
|
80
|
+
)
|
|
81
|
+
return AccountDeleteResponse(status="deleted")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import delete, select, update
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from app.db.models import Account, AccountStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AccountsRepository:
|
|
12
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
13
|
+
self._session = session
|
|
14
|
+
|
|
15
|
+
async def list_accounts(self) -> list[Account]:
|
|
16
|
+
result = await self._session.execute(select(Account).order_by(Account.email))
|
|
17
|
+
return list(result.scalars().all())
|
|
18
|
+
|
|
19
|
+
async def upsert(self, account: Account) -> Account:
|
|
20
|
+
existing = await self._session.get(Account, account.id)
|
|
21
|
+
if existing:
|
|
22
|
+
existing.email = account.email
|
|
23
|
+
existing.plan_type = account.plan_type
|
|
24
|
+
existing.access_token_encrypted = account.access_token_encrypted
|
|
25
|
+
existing.refresh_token_encrypted = account.refresh_token_encrypted
|
|
26
|
+
existing.id_token_encrypted = account.id_token_encrypted
|
|
27
|
+
existing.last_refresh = account.last_refresh
|
|
28
|
+
existing.status = account.status
|
|
29
|
+
existing.deactivation_reason = account.deactivation_reason
|
|
30
|
+
await self._session.commit()
|
|
31
|
+
await self._session.refresh(existing)
|
|
32
|
+
return existing
|
|
33
|
+
|
|
34
|
+
self._session.add(account)
|
|
35
|
+
await self._session.commit()
|
|
36
|
+
await self._session.refresh(account)
|
|
37
|
+
return account
|
|
38
|
+
|
|
39
|
+
async def update_status(
|
|
40
|
+
self,
|
|
41
|
+
account_id: str,
|
|
42
|
+
status: AccountStatus,
|
|
43
|
+
deactivation_reason: str | None = None,
|
|
44
|
+
) -> bool:
|
|
45
|
+
result = await self._session.execute(
|
|
46
|
+
update(Account)
|
|
47
|
+
.where(Account.id == account_id)
|
|
48
|
+
.values(status=status, deactivation_reason=deactivation_reason)
|
|
49
|
+
)
|
|
50
|
+
await self._session.commit()
|
|
51
|
+
return bool(result.rowcount)
|
|
52
|
+
|
|
53
|
+
async def delete(self, account_id: str) -> bool:
|
|
54
|
+
result = await self._session.execute(delete(Account).where(Account.id == account_id))
|
|
55
|
+
await self._session.commit()
|
|
56
|
+
return bool(result.rowcount)
|
|
57
|
+
|
|
58
|
+
async def update_tokens(
|
|
59
|
+
self,
|
|
60
|
+
account_id: str,
|
|
61
|
+
access_token_encrypted: bytes,
|
|
62
|
+
refresh_token_encrypted: bytes,
|
|
63
|
+
id_token_encrypted: bytes,
|
|
64
|
+
last_refresh: datetime,
|
|
65
|
+
plan_type: str | None = None,
|
|
66
|
+
email: str | None = None,
|
|
67
|
+
) -> bool:
|
|
68
|
+
values = {
|
|
69
|
+
"access_token_encrypted": access_token_encrypted,
|
|
70
|
+
"refresh_token_encrypted": refresh_token_encrypted,
|
|
71
|
+
"id_token_encrypted": id_token_encrypted,
|
|
72
|
+
"last_refresh": last_refresh,
|
|
73
|
+
}
|
|
74
|
+
if plan_type is not None:
|
|
75
|
+
values["plan_type"] = plan_type
|
|
76
|
+
if email is not None:
|
|
77
|
+
values["email"] = email
|
|
78
|
+
result = await self._session.execute(update(Account).where(Account.id == account_id).values(**values))
|
|
79
|
+
await self._session.commit()
|
|
80
|
+
return bool(result.rowcount)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
|
|
8
|
+
from app.modules.shared.schemas import DashboardModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AccountUsage(DashboardModel):
|
|
12
|
+
primary_remaining_percent: float | None = None
|
|
13
|
+
secondary_remaining_percent: float | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AccountTokenStatus(DashboardModel):
|
|
17
|
+
expires_at: datetime | None = None
|
|
18
|
+
state: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AccountAuthStatus(DashboardModel):
|
|
22
|
+
access: AccountTokenStatus | None = None
|
|
23
|
+
refresh: AccountTokenStatus | None = None
|
|
24
|
+
id_token: AccountTokenStatus | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AccountSummary(DashboardModel):
|
|
28
|
+
account_id: str
|
|
29
|
+
email: str
|
|
30
|
+
display_name: str
|
|
31
|
+
plan_type: str
|
|
32
|
+
status: str
|
|
33
|
+
usage: AccountUsage | None = None
|
|
34
|
+
reset_at_primary: datetime | None = None
|
|
35
|
+
reset_at_secondary: datetime | None = None
|
|
36
|
+
last_refresh_at: datetime | None = None
|
|
37
|
+
capacity_credits_primary: float | None = None
|
|
38
|
+
remaining_credits_primary: float | None = None
|
|
39
|
+
capacity_credits_secondary: float | None = None
|
|
40
|
+
remaining_credits_secondary: float | None = None
|
|
41
|
+
cost_usd_24h: float | None = None
|
|
42
|
+
deactivation_reason: str | None = None
|
|
43
|
+
auth: AccountAuthStatus | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AccountsResponse(DashboardModel):
|
|
47
|
+
accounts: List[AccountSummary] = Field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AccountImportResponse(DashboardModel):
|
|
51
|
+
account_id: str
|
|
52
|
+
email: str
|
|
53
|
+
plan_type: str
|
|
54
|
+
status: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AccountPauseResponse(DashboardModel):
|
|
58
|
+
status: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AccountReactivateResponse(DashboardModel):
|
|
62
|
+
status: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AccountDeleteResponse(DashboardModel):
|
|
66
|
+
status: str
|