Download api/accounts.py from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 44.6 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/api/accounts.py
- Command line
-
hf download hf://spaces/zlcy/study/api/accounts.py
-
curl -L -o accounts.py https://huggingface.co/spaces/zlcy/study/resolve/main/api/accounts.py
44.6 kB
| from __future__ import annotations | |
| import asyncio | |
| import io | |
| import json | |
| import re | |
| import uuid | |
| import zipfile | |
| from datetime import datetime | |
| from threading import Lock | |
| from typing import Any, Literal | |
| from fastapi import APIRouter, Header, HTTPException, Query | |
| from fastapi.concurrency import run_in_threadpool | |
| from fastapi.responses import Response | |
| from pydantic import AliasChoices, BaseModel, ConfigDict, Field | |
| from services.auth_service import auth_service | |
| from services.account_email_login_service import account_email_login_import_service | |
| from api.support import ( | |
| require_admin, | |
| require_identity, | |
| sanitize_cpa_pool, | |
| sanitize_cpa_pools, | |
| sanitize_sub2api_server, | |
| sanitize_sub2api_servers, | |
| ) | |
| from services.account_service import account_service | |
| from services.chatgpt_session_service import ChatGPTSessionStoreError, chatgpt_session_service | |
| from services.oauth_login_service import OAuthLoginError, oauth_login_service | |
| from services.cpa_service import cpa_config, cpa_import_service, list_remote_files | |
| from services.upstream_account_maintenance_service import upstream_account_maintenance_service | |
| from services.sub2api_service import ( | |
| list_remote_accounts as sub2api_list_remote_accounts, | |
| list_remote_groups as sub2api_list_remote_groups, | |
| sub2api_config, | |
| sub2api_import_service, | |
| ) | |
| _refresh_jobs: dict[str, dict[str, Any]] = {} | |
| _refresh_jobs_lock = Lock() | |
| def _set_refresh_job(progress_id: str, **updates: Any) -> None: | |
| with _refresh_jobs_lock: | |
| job = _refresh_jobs.get(progress_id) | |
| if job is not None: | |
| job.update(updates) | |
| def _get_refresh_job(progress_id: str) -> dict[str, Any] | None: | |
| with _refresh_jobs_lock: | |
| job = _refresh_jobs.get(progress_id) | |
| return dict(job) if job else None | |
| class UserKeyCreateRequest(BaseModel): | |
| name: str = "" | |
| class UserKeyUpdateRequest(BaseModel): | |
| model_config = ConfigDict(populate_by_name=True) | |
| name: str | None = None | |
| enabled: bool | None = None | |
| account_pool_enabled: bool | None = Field( | |
| default=None, | |
| validation_alias=AliasChoices("account_pool_enabled", "accountPoolEnabled"), | |
| ) | |
| image_memory_button_enabled: bool | None = Field( | |
| default=None, | |
| validation_alias=AliasChoices("image_memory_button_enabled", "imageMemoryButtonEnabled"), | |
| ) | |
| third_party_image_enabled: bool | None = Field( | |
| default=None, | |
| validation_alias=AliasChoices("third_party_image_enabled", "thirdPartyImageEnabled"), | |
| ) | |
| third_party_image_group_ids: list[str] | None = Field( | |
| default=None, | |
| validation_alias=AliasChoices("third_party_image_group_ids", "thirdPartyImageGroupIds"), | |
| ) | |
| third_party_image_model_ids: list[str] | None = Field( | |
| default=None, | |
| validation_alias=AliasChoices("third_party_image_model_ids", "thirdPartyImageModelIds"), | |
| ) | |
| image_model_daily_limits: list[dict[str, Any]] | None = Field( | |
| default=None, | |
| validation_alias=AliasChoices("image_model_daily_limits", "imageModelDailyLimits"), | |
| ) | |
| key: str | None = None | |
| class UserKeyBatchRequest(BaseModel): | |
| ids: list[str] = Field(default_factory=list) | |
| action: Literal["enable", "disable", "delete"] | |
| class UserImagePermissionBatchItem(BaseModel): | |
| id: str | |
| third_party_image_enabled: bool | None = None | |
| third_party_image_group_ids: list[str] | None = None | |
| third_party_image_model_ids: list[str] | None = None | |
| class UserImagePermissionBatchRequest(BaseModel): | |
| items: list[UserImagePermissionBatchItem] = Field(default_factory=list) | |
| class AccountCreateRequest(BaseModel): | |
| tokens: list[str] = Field(default_factory=list) | |
| accounts: list[dict[str, Any]] = Field(default_factory=list) | |
| class AccountDeleteRequest(BaseModel): | |
| tokens: list[str] = Field(default_factory=list) | |
| class AccountRefreshRequest(BaseModel): | |
| access_tokens: list[str] = Field(default_factory=list) | |
| class AccountReloginRequest(BaseModel): | |
| access_token: str = "" | |
| class AccountReloginVerifyRequest(BaseModel): | |
| relogin_id: str = "" | |
| code: str = "" | |
| class AccountUpstreamActionsRequest(BaseModel): | |
| access_tokens: list[str] = Field(default_factory=list) | |
| disable_memory: bool = False | |
| hide_conversations: bool = False | |
| delete_files: bool = False | |
| file_page_limit: int = Field(default=100, ge=1, le=100) | |
| class AccountExportRequest(BaseModel): | |
| access_tokens: list[str] = Field(default_factory=list) | |
| format: Literal["json", "zip"] = "json" | |
| class AccountUpdateRequest(BaseModel): | |
| access_token: str = "" | |
| type: str | None = None | |
| status: str | None = None | |
| quota: int | None = None | |
| shared: bool | None = None | |
| class AccountEmailLoginStartRequest(BaseModel): | |
| email: str = "" | |
| class AccountEmailLoginVerifyRequest(BaseModel): | |
| session_id: str = "" | |
| code: str = "" | |
| class OAuthLoginStartRequest(BaseModel): | |
| email_hint: str = "" | |
| class OAuthLoginFinishRequest(BaseModel): | |
| session_id: str = "" | |
| callback: str = "" | |
| class CPAPoolCreateRequest(BaseModel): | |
| name: str = "" | |
| base_url: str = "" | |
| secret_key: str = "" | |
| class CPAPoolUpdateRequest(BaseModel): | |
| name: str | None = None | |
| base_url: str | None = None | |
| secret_key: str | None = None | |
| class CPAImportRequest(BaseModel): | |
| names: list[str] = Field(default_factory=list) | |
| class Sub2APIServerCreateRequest(BaseModel): | |
| name: str = "" | |
| base_url: str = "" | |
| email: str = "" | |
| password: str = "" | |
| api_key: str = "" | |
| group_id: str = "" | |
| class Sub2APIServerUpdateRequest(BaseModel): | |
| name: str | None = None | |
| base_url: str | None = None | |
| email: str | None = None | |
| password: str | None = None | |
| api_key: str | None = None | |
| group_id: str | None = None | |
| class Sub2APIImportRequest(BaseModel): | |
| account_ids: list[str] = Field(default_factory=list) | |
| def _account_payload_token(item: dict[str, Any]) -> str: | |
| return str(item.get("access_token") or item.get("accessToken") or "").strip() | |
| def _public_account(item: dict[str, Any]) -> dict[str, Any]: | |
| result = dict(item) | |
| result["has_password"] = bool(str(result.pop("password", "") or "").strip()) | |
| return result | |
| def _unique_tokens(tokens: list[str]) -> list[str]: | |
| return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip())) | |
| def _download_timestamp() -> str: | |
| return datetime.now().strftime("%Y%m%d-%H%M%S") | |
| def _safe_export_name(value: str, fallback: str) -> str: | |
| clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._") | |
| return (clean or fallback)[:80] | |
| def _account_zip_bytes(items: list[dict[str, str]]) -> bytes: | |
| buf = io.BytesIO() | |
| used_names: set[str] = set() | |
| with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: | |
| for index, item in enumerate(items, start=1): | |
| raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}" | |
| base_name = _safe_export_name(raw_name, f"account-{index:03d}") | |
| name = base_name | |
| suffix = 2 | |
| while name in used_names: | |
| name = f"{base_name}-{suffix}" | |
| suffix += 1 | |
| used_names.add(name) | |
| archive.writestr( | |
| f"{name}.json", | |
| json.dumps(item, ensure_ascii=False, indent=2) + "\n", | |
| ) | |
| return buf.getvalue() | |
| def _has_personal_account_pool(identity: dict[str, object]) -> bool: | |
| return identity.get("role") == "admin" or bool(identity.get("account_pool_enabled")) | |
| def _require_personal_account_pool(identity: dict[str, object]) -> None: | |
| if _has_personal_account_pool(identity): | |
| return | |
| raise HTTPException(status_code=403, detail={"error": "管理员尚未为你开启个人号池"}) | |
| def _quota_identity(identity: dict[str, object]) -> dict[str, object]: | |
| return identity if _has_personal_account_pool(identity) else {"id": "", "role": "user", "name": identity.get("name")} | |
| def create_router() -> APIRouter: | |
| router = APIRouter() | |
| async def list_user_keys(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return {"items": auth_service.list_keys(role="user")} | |
| async def list_normal_user_keys(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return {"items": auth_service.list_keys(role="normal")} | |
| async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| item, raw_key = auth_service.create_key(role="user", name=body.name) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")} | |
| async def create_normal_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| item, raw_key = auth_service.create_key(role="normal", name=body.name) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="normal")} | |
| async def batch_user_keys(body: UserKeyBatchRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| result = await run_in_threadpool( | |
| auth_service.batch_mutate_keys, | |
| body.ids, | |
| action=body.action, | |
| role="user", | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| except Exception as exc: | |
| print(f"[auth-api] batch internal users failed: {exc}") | |
| raise HTTPException(status_code=503, detail={"error": "批量操作内部用户失败,请检查存储后端日志"}) from exc | |
| return {"result": result, "items": auth_service.list_keys(role="user")} | |
| async def batch_normal_user_keys(body: UserKeyBatchRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| result = await run_in_threadpool( | |
| auth_service.batch_mutate_keys, | |
| body.ids, | |
| action=body.action, | |
| role="normal", | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| except Exception as exc: | |
| print(f"[auth-api] batch normal users failed: {exc}") | |
| raise HTTPException(status_code=503, detail={"error": "批量操作普通用户失败,请检查存储后端日志"}) from exc | |
| return {"result": result, "items": auth_service.list_keys(role="normal")} | |
| async def batch_user_image_permissions( | |
| user_kind: Literal["users", "normal-users"], | |
| body: UserImagePermissionBatchRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| require_admin(authorization) | |
| role: Literal["user", "normal"] = "normal" if user_kind == "normal-users" else "user" | |
| try: | |
| result = await run_in_threadpool( | |
| auth_service.batch_update_image_permissions, | |
| [item.model_dump(exclude_none=True) for item in body.items], | |
| role=role, | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| except Exception as exc: | |
| print(f"[auth-api] batch image permissions failed: {exc}") | |
| raise HTTPException(status_code=503, detail={"error": "批量更新图片权限失败,请检查存储后端日志"}) from exc | |
| return {"result": result, "items": auth_service.list_keys(role=role)} | |
| async def update_user_key( | |
| key_id: str, | |
| body: UserKeyUpdateRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| require_admin(authorization) | |
| updates = { | |
| key: value | |
| for key, value in { | |
| "name": body.name, | |
| "enabled": body.enabled, | |
| "account_pool_enabled": body.account_pool_enabled, | |
| "image_memory_button_enabled": body.image_memory_button_enabled, | |
| "third_party_image_enabled": body.third_party_image_enabled, | |
| "third_party_image_group_ids": body.third_party_image_group_ids, | |
| "third_party_image_model_ids": body.third_party_image_model_ids, | |
| "image_model_daily_limits": body.image_model_daily_limits, | |
| "key": body.key, | |
| }.items() | |
| if value is not None | |
| } | |
| if not updates: | |
| raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"}) | |
| try: | |
| item = auth_service.update_key(key_id, updates, role="user") | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| if item is None: | |
| raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"}) | |
| return {"item": item, "items": auth_service.list_keys(role="user")} | |
| async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| deleted = auth_service.delete_key(key_id, role="user") | |
| except Exception as exc: | |
| print(f"[auth-api] delete internal user {key_id} failed: {exc}") | |
| raise HTTPException( | |
| status_code=503, | |
| detail={"error": "删除内部用户失败,请检查存储后端日志"}, | |
| ) from exc | |
| if not deleted: | |
| raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"}) | |
| return {"items": auth_service.list_keys(role="user")} | |
| async def update_normal_user_key( | |
| key_id: str, | |
| body: UserKeyUpdateRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| require_admin(authorization) | |
| updates = { | |
| key: value | |
| for key, value in { | |
| "name": body.name, | |
| "enabled": body.enabled, | |
| "account_pool_enabled": body.account_pool_enabled, | |
| "image_memory_button_enabled": body.image_memory_button_enabled, | |
| "third_party_image_enabled": body.third_party_image_enabled, | |
| "third_party_image_group_ids": body.third_party_image_group_ids, | |
| "third_party_image_model_ids": body.third_party_image_model_ids, | |
| "image_model_daily_limits": body.image_model_daily_limits, | |
| "key": body.key, | |
| }.items() | |
| if value is not None | |
| } | |
| if not updates: | |
| raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"}) | |
| try: | |
| item = auth_service.update_key(key_id, updates, role="normal") | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| if item is None: | |
| raise HTTPException(status_code=404, detail={"error": "这条普通用户密钥不存在,可能已经被删除"}) | |
| return {"item": item, "items": auth_service.list_keys(role="normal")} | |
| async def delete_normal_user_key(key_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| deleted = auth_service.delete_key(key_id, role="normal") | |
| except Exception as exc: | |
| print(f"[auth-api] delete normal user {key_id} failed: {exc}") | |
| raise HTTPException( | |
| status_code=503, | |
| detail={"error": "删除普通用户失败,请检查存储后端日志"}, | |
| ) from exc | |
| if not deleted: | |
| raise HTTPException(status_code=404, detail={"error": "这条普通用户密钥不存在,可能已经被删除"}) | |
| return {"items": auth_service.list_keys(role="normal")} | |
| async def get_accounts(authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| return {"items": account_service.list_accounts_for_identity(identity)} | |
| async def get_quota_summary( | |
| strategy: str = Query(default="own_first"), | |
| allow_shared: bool = Query(default=True), | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| effective_identity = _quota_identity(identity) | |
| effective_strategy = strategy if _has_personal_account_pool(identity) else "admin_only" | |
| return account_service.image_quota_summary_for_identity( | |
| effective_identity, | |
| strategy=effective_strategy, | |
| allow_shared=allow_shared and _has_personal_account_pool(identity), | |
| ) | |
| async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| existing_account_keys = { | |
| _account_payload_token(account): str(account.get("account_key") or "").strip() | |
| for account in account_service.list_accounts_for_identity(identity) | |
| if _account_payload_token(account) | |
| } | |
| account_payloads: list[dict[str, Any]] = [] | |
| for source in body.accounts: | |
| if not isinstance(source, dict): | |
| continue | |
| item = dict(source) | |
| snake_session_token = item.pop("session_token", "") | |
| camel_session_token = item.pop("sessionToken", "") | |
| session_token = str(snake_session_token or camel_session_token or "").strip() | |
| if session_token: | |
| access_token = _account_payload_token(item) | |
| account_key = str( | |
| item.get("account_key") or existing_account_keys.get(access_token) or uuid.uuid4() | |
| ).strip() | |
| session_expires_at = str( | |
| item.get("session_expires_at") or item.get("expires") or "" | |
| ).strip() | |
| try: | |
| chatgpt_session_service.capture_token( | |
| account_key, | |
| session_token, | |
| { | |
| "session_expires_at": session_expires_at, | |
| "fp": item.get("fp"), | |
| "registered_proxy": item.get("registered_proxy"), | |
| }, | |
| ) | |
| except (ValueError, ChatGPTSessionStoreError) as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| item.update( | |
| { | |
| "account_key": account_key, | |
| "source_type": "chatgpt_session", | |
| "has_renewable_session": True, | |
| "session_expires_at": session_expires_at or None, | |
| } | |
| ) | |
| account_payloads.append(item) | |
| payload_tokens = [_account_payload_token(item) for item in account_payloads] | |
| tokens = _unique_tokens([*body.tokens, *payload_tokens]) | |
| if not tokens: | |
| raise HTTPException(status_code=400, detail={"error": "tokens is required"}) | |
| if account_payloads: | |
| result = account_service.add_account_items(account_payloads, owner=identity) | |
| payload_token_set = set(_unique_tokens(payload_tokens)) | |
| extra_tokens = [token for token in tokens if token not in payload_token_set] | |
| if extra_tokens: | |
| extra_result = account_service.add_accounts(extra_tokens, owner=identity) | |
| result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0) | |
| result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0) | |
| else: | |
| result = account_service.add_accounts(tokens, owner=identity) | |
| allowed_tokens = { | |
| str(item.get("access_token") or "") | |
| for item in account_service.list_accounts_for_identity(identity) | |
| } | |
| refresh_targets = [token for token in tokens if token in allowed_tokens] | |
| refresh_result = account_service.refresh_accounts(refresh_targets) if refresh_targets else {"refreshed": 0, "errors": []} | |
| return { | |
| **result, | |
| "refreshed": refresh_result.get("refreshed", 0), | |
| "errors": refresh_result.get("errors", []), | |
| "blocked": refresh_result.get("blocked", 0), | |
| "items": account_service.list_accounts_for_identity(identity), | |
| } | |
| async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()] | |
| if not tokens: | |
| raise HTTPException(status_code=400, detail={"error": "tokens is required"}) | |
| result = account_service.delete_accounts( | |
| tokens, | |
| owner_id=str(identity.get("id") or ""), | |
| admin=identity.get("role") == "admin", | |
| ) | |
| return {**result, "items": account_service.list_accounts_for_identity(identity)} | |
| async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()] | |
| if not access_tokens: | |
| access_tokens = [ | |
| str(item.get("access_token") or "") | |
| for item in account_service.list_accounts_for_identity(identity) | |
| ] | |
| if not access_tokens: | |
| raise HTTPException(status_code=400, detail={"error": "access_tokens is required"}) | |
| allowed_tokens = { | |
| str(item.get("access_token") or "") | |
| for item in account_service.list_accounts_for_identity(identity) | |
| } | |
| access_tokens = [token for token in access_tokens if token in allowed_tokens] | |
| if not access_tokens: | |
| raise HTTPException(status_code=403, detail={"error": "no permitted accounts selected"}) | |
| progress_id = uuid.uuid4().hex | |
| owner_id = str(identity.get("id") or "") | |
| with _refresh_jobs_lock: | |
| if len(_refresh_jobs) > 200: | |
| completed = [key for key, value in _refresh_jobs.items() if value.get("done")] | |
| for key in completed[:100]: | |
| _refresh_jobs.pop(key, None) | |
| _refresh_jobs[progress_id] = { | |
| "progress_id": progress_id, | |
| "owner_id": owner_id, | |
| "total": len(access_tokens), | |
| "processed": 0, | |
| "refreshed": 0, | |
| "failed": 0, | |
| "done": False, | |
| "error": None, | |
| } | |
| def update_progress(_token: str, success: bool, _error: str | None) -> None: | |
| with _refresh_jobs_lock: | |
| job = _refresh_jobs.get(progress_id) | |
| if job is None: | |
| return | |
| job["processed"] = int(job.get("processed") or 0) + 1 | |
| key = "refreshed" if success else "failed" | |
| job[key] = int(job.get(key) or 0) + 1 | |
| async def run_refresh() -> None: | |
| try: | |
| result = await run_in_threadpool( | |
| account_service.refresh_accounts, | |
| access_tokens, | |
| # A definitive upstream 401 means the saved credential can no | |
| # longer authenticate. Surface that state to the operator on | |
| # a manual refresh, but never delete an account from this UI | |
| # action (transient 403/429/network failures are not treated as | |
| # invalid by AccountService). | |
| defer_invalid_removal=False, | |
| cleanup_action="mark_abnormal", | |
| invalid_failure_threshold=1, | |
| progress_callback=update_progress, | |
| ) | |
| _set_refresh_job( | |
| progress_id, | |
| done=True, | |
| processed=len(access_tokens), | |
| refreshed=int(result.get("refreshed") or 0), | |
| failed=len(result.get("errors") or []), | |
| result={**result, "items": account_service.list_accounts_for_identity(identity)}, | |
| ) | |
| except Exception as exc: | |
| _set_refresh_job(progress_id, done=True, error=str(exc)) | |
| asyncio.create_task(run_refresh()) | |
| return {"progress_id": progress_id, "total": len(access_tokens)} | |
| async def re_login_account( | |
| body: AccountReloginRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| token = str(body.access_token or "").strip() | |
| if not token: | |
| raise HTTPException(status_code=400, detail={"error": "access_token is required"}) | |
| try: | |
| return await run_in_threadpool( | |
| account_service.re_login_with_password, | |
| token, | |
| owner_id=str(identity.get("id") or ""), | |
| admin=str(identity.get("role") or "") == "admin", | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| except RuntimeError as exc: | |
| raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc | |
| async def verify_re_login_account( | |
| body: AccountReloginVerifyRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| try: | |
| return await run_in_threadpool( | |
| account_service.verify_re_login_with_password, | |
| str(body.relogin_id or "").strip(), | |
| str(body.code or "").strip(), | |
| owner_id=str(identity.get("id") or ""), | |
| admin=str(identity.get("role") or "") == "admin", | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| except RuntimeError as exc: | |
| raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc | |
| async def get_refresh_progress(progress_id: str, authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| progress = _get_refresh_job(progress_id) | |
| if progress is None: | |
| raise HTTPException(status_code=404, detail={"error": "progress not found"}) | |
| if str(progress.get("owner_id") or "") != str(identity.get("id") or "") and identity.get("role") != "admin": | |
| raise HTTPException(status_code=403, detail={"error": "no permission for this progress"}) | |
| progress.pop("owner_id", None) | |
| return progress | |
| async def run_upstream_account_actions( | |
| body: AccountUpstreamActionsRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| if not any((body.disable_memory, body.hide_conversations, body.delete_files)): | |
| raise HTTPException(status_code=400, detail={"error": "at least one upstream action is required"}) | |
| visible_accounts = account_service.list_accounts_for_identity(identity) | |
| allowed_by_token = { | |
| str(item.get("access_token") or "").strip(): item | |
| for item in visible_accounts | |
| if str(item.get("access_token") or "").strip() | |
| } | |
| requested_tokens = _unique_tokens(body.access_tokens) | |
| if not requested_tokens: | |
| raise HTTPException(status_code=400, detail={"error": "access_tokens is required"}) | |
| if any(token not in allowed_by_token for token in requested_tokens): | |
| raise HTTPException(status_code=403, detail={"error": "no permission for one or more selected accounts"}) | |
| if len(requested_tokens) > 200: | |
| raise HTTPException(status_code=400, detail={"error": "up to 200 accounts can be maintained at once"}) | |
| if body.delete_files and len(requested_tokens) > 20: | |
| raise HTTPException(status_code=400, detail={"error": "attachment deletion supports up to 20 accounts at once"}) | |
| return await run_in_threadpool( | |
| upstream_account_maintenance_service.run, | |
| requested_tokens, | |
| disable_memory=body.disable_memory, | |
| hide_conversations=body.hide_conversations, | |
| delete_files=body.delete_files, | |
| file_page_limit=body.file_page_limit, | |
| ) | |
| async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| access_tokens = _unique_tokens(body.access_tokens) | |
| allowed_tokens = { | |
| str(item.get("access_token") or "") | |
| for item in account_service.list_accounts_for_identity(identity) | |
| } | |
| access_tokens = [token for token in access_tokens if token in allowed_tokens] if access_tokens else list(allowed_tokens) | |
| items = account_service.build_export_items(access_tokens) | |
| if not items: | |
| raise HTTPException( | |
| status_code=400, | |
| detail={"error": "没有可导出的账号,账号至少需要包含 access_token"}, | |
| ) | |
| timestamp = _download_timestamp() | |
| if body.format == "zip": | |
| content = _account_zip_bytes(items) | |
| return Response( | |
| content, | |
| media_type="application/zip", | |
| headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'}, | |
| ) | |
| payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items | |
| return Response( | |
| json.dumps(payload, ensure_ascii=False, indent=2) + "\n", | |
| media_type="application/json", | |
| headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'}, | |
| ) | |
| async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| access_token = str(body.access_token or "").strip() | |
| if not access_token: | |
| raise HTTPException(status_code=400, detail={"error": "access_token is required"}) | |
| role = str(identity.get("role") or "").strip().lower() | |
| update_source = {"status": body.status, "shared": body.shared} | |
| if role == "admin": | |
| update_source.update({"type": body.type, "quota": body.quota}) | |
| updates = {key: value for key, value in update_source.items() if value is not None} | |
| if not updates: | |
| raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"}) | |
| account = account_service.update_account( | |
| access_token, | |
| updates, | |
| owner_id=str(identity.get("id") or ""), | |
| admin=role == "admin", | |
| ) | |
| if account is None: | |
| raise HTTPException(status_code=404, detail={"error": "account not found"}) | |
| return {"item": _public_account(account), "items": account_service.list_accounts_for_identity(identity)} | |
| async def start_account_email_login_import( | |
| body: AccountEmailLoginStartRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| try: | |
| return await run_in_threadpool( | |
| account_email_login_import_service.start, | |
| body.email, | |
| identity, | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| except RuntimeError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def start_oauth_login( | |
| body: OAuthLoginStartRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| try: | |
| return await run_in_threadpool(oauth_login_service.start, body.email_hint) | |
| except OAuthLoginError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def finish_oauth_login( | |
| body: OAuthLoginFinishRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| try: | |
| tokens = await run_in_threadpool( | |
| oauth_login_service.finish, | |
| body.session_id, | |
| body.callback, | |
| ) | |
| except OAuthLoginError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| payload = { | |
| "access_token": tokens["access_token"], | |
| "refresh_token": tokens["refresh_token"], | |
| "id_token": tokens["id_token"], | |
| "source_type": "oauth_login", | |
| } | |
| add_result = await run_in_threadpool( | |
| account_service.add_account_items, | |
| [payload], | |
| owner=identity, | |
| ) | |
| refresh_result = await run_in_threadpool( | |
| account_service.refresh_accounts, | |
| [tokens["access_token"]], | |
| ) | |
| return { | |
| **add_result, | |
| "refreshed": refresh_result.get("refreshed", 0), | |
| "errors": refresh_result.get("errors", []), | |
| "items": account_service.list_accounts_for_identity(identity), | |
| } | |
| async def verify_account_email_login_import( | |
| body: AccountEmailLoginVerifyRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| _require_personal_account_pool(identity) | |
| try: | |
| return await run_in_threadpool( | |
| account_email_login_import_service.verify, | |
| body.session_id, | |
| body.code, | |
| identity, | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| except RuntimeError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def list_cpa_pools(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return {"pools": sanitize_cpa_pools(cpa_config.list_pools())} | |
| async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| if not body.base_url.strip(): | |
| raise HTTPException(status_code=400, detail={"error": "base_url is required"}) | |
| if not body.secret_key.strip(): | |
| raise HTTPException(status_code=400, detail={"error": "secret_key is required"}) | |
| pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key) | |
| return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())} | |
| async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True)) | |
| if pool is None: | |
| raise HTTPException(status_code=404, detail={"error": "pool not found"}) | |
| return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())} | |
| async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| if not cpa_config.delete_pool(pool_id): | |
| raise HTTPException(status_code=404, detail={"error": "pool not found"}) | |
| return {"pools": sanitize_cpa_pools(cpa_config.list_pools())} | |
| async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| pool = cpa_config.get_pool(pool_id) | |
| if pool is None: | |
| raise HTTPException(status_code=404, detail={"error": "pool not found"}) | |
| return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)} | |
| async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| pool = cpa_config.get_pool(pool_id) | |
| if pool is None: | |
| raise HTTPException(status_code=404, detail={"error": "pool not found"}) | |
| try: | |
| job = cpa_import_service.start_import(pool, body.names) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| return {"import_job": job} | |
| async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| pool = cpa_config.get_pool(pool_id) | |
| if pool is None: | |
| raise HTTPException(status_code=404, detail={"error": "pool not found"}) | |
| return {"import_job": pool.get("import_job")} | |
| async def list_sub2api_servers(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())} | |
| async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| if not body.base_url.strip(): | |
| raise HTTPException(status_code=400, detail={"error": "base_url is required"}) | |
| has_login = body.email.strip() and body.password.strip() | |
| has_api_key = bool(body.api_key.strip()) | |
| if not has_login and not has_api_key: | |
| raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"}) | |
| server = sub2api_config.add_server( | |
| name=body.name, | |
| base_url=body.base_url, | |
| email=body.email, | |
| password=body.password, | |
| api_key=body.api_key, | |
| group_id=body.group_id, | |
| ) | |
| return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())} | |
| async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True)) | |
| if server is None: | |
| raise HTTPException(status_code=404, detail={"error": "server not found"}) | |
| return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())} | |
| async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| if not sub2api_config.delete_server(server_id): | |
| raise HTTPException(status_code=404, detail={"error": "server not found"}) | |
| return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())} | |
| async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| server = sub2api_config.get_server(server_id) | |
| if server is None: | |
| raise HTTPException(status_code=404, detail={"error": "server not found"}) | |
| try: | |
| groups = await run_in_threadpool(sub2api_list_remote_groups, server) | |
| except Exception as exc: | |
| raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc | |
| return {"server_id": server_id, "groups": groups} | |
| async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| server = sub2api_config.get_server(server_id) | |
| if server is None: | |
| raise HTTPException(status_code=404, detail={"error": "server not found"}) | |
| try: | |
| accounts = await run_in_threadpool(sub2api_list_remote_accounts, server) | |
| except Exception as exc: | |
| raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc | |
| return {"server_id": server_id, "accounts": accounts} | |
| async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| server = sub2api_config.get_server(server_id) | |
| if server is None: | |
| raise HTTPException(status_code=404, detail={"error": "server not found"}) | |
| try: | |
| job = sub2api_import_service.start_import(server, body.account_ids) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| return {"import_job": job} | |
| async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| server = sub2api_config.get_server(server_id) | |
| if server is None: | |
| raise HTTPException(status_code=404, detail={"error": "server not found"}) | |
| return {"import_job": server.get("import_job")} | |
| return router | |