AutoTeam-F / src /autoteam /sub2api_sync.py
ZRainbow's picture
feat: AutoTeam 大版本升级:安全轮换、并行、IPv6、指纹加固与前端重构
1f4cfcb
Raw
History Blame Contribute Delete
34.1 kB
"""Sub2API 账号同步。"""
from __future__ import annotations
import base64
import json
import logging
import time
from datetime import datetime, timezone
from pathlib import Path
import requests
from autoteam.codex_auth import CODEX_CLIENT_ID
from autoteam.config import (
SUB2API_AUTO_PAUSE_ON_EXPIRED,
SUB2API_CONCURRENCY,
SUB2API_EMAIL,
SUB2API_GROUP,
SUB2API_MODEL_WHITELIST,
SUB2API_OPENAI_PASSTHROUGH,
SUB2API_OPENAI_WS_MODE,
SUB2API_OVERWRITE_ACCOUNT_SETTINGS,
SUB2API_PASSWORD,
SUB2API_PRIORITY,
SUB2API_PROXY,
SUB2API_RATE_MULTIPLIER,
SUB2API_URL,
)
from autoteam.textio import read_text
logger = logging.getLogger(__name__)
_TIMEOUT = 10
_PAGE_SIZE = 200
_EXTRA_MANAGED = "autoteam_managed"
_EXTRA_KIND = "autoteam_kind"
_EXTRA_EMAIL = "autoteam_email"
_EXTRA_AUTH_FILE = "autoteam_auth_file"
_EXTRA_SOURCE = "autoteam_source"
_EXTRA_LAST_SYNC_AT = "autoteam_last_sync_at"
_EXTRA_GROUP_IDS = "autoteam_sub2api_group_ids"
_EXTRA_GROUP_NAMES = "autoteam_sub2api_group_names"
_KIND_POOL = "pool"
_KIND_MAIN = "main"
_REMOTE_AUTH_FILE_PREFIX = "sub2api-"
def _excerpt(text: str | bytes | None, limit: int = 200) -> str:
value = text.decode("utf-8", errors="ignore") if isinstance(text, bytes) else str(text or "")
value = value.strip().replace("\n", " ")
if len(value) > limit:
value = value[:limit] + "..."
return value
def _api_base_url() -> str:
base = (SUB2API_URL or "").strip().rstrip("/")
if base.endswith("/api/v1"):
return base
if base.endswith("/api"):
return f"{base}/v1"
return f"{base}/api/v1"
def _request(method: str, path: str, *, token: str | None = None, label: str, **kwargs):
url = f"{_api_base_url()}{path}"
headers = dict(kwargs.pop("headers", {}) or {})
if token:
headers["Authorization"] = f"Bearer {token}"
resp = requests.request(method, url, headers=headers, timeout=_TIMEOUT, **kwargs)
try:
payload = resp.json()
except Exception as exc:
raise RuntimeError(f"[Sub2API] {label}返回了非 JSON 内容: {_excerpt(resp.text)}") from exc
if resp.status_code != 200:
message = payload.get("message") or payload.get("detail") or _excerpt(resp.text)
raise RuntimeError(f"[Sub2API] {label}失败: HTTP {resp.status_code} {message}")
if payload.get("code", 0) != 0:
message = payload.get("message") or _excerpt(resp.text)
raise RuntimeError(f"[Sub2API] {label}失败: {message}")
return payload.get("data")
def _login() -> str:
data = _request(
"POST",
"/auth/login",
label="管理员登录",
json={"email": SUB2API_EMAIL, "password": SUB2API_PASSWORD},
)
if not isinstance(data, dict):
raise RuntimeError("[Sub2API] 管理员登录返回格式异常")
if data.get("requires_2fa"):
raise RuntimeError("[Sub2API] 当前账号启用了 2FA,AutoTeam 暂不支持自动同步到 Sub2API")
access_token = (data.get("access_token") or "").strip()
if not access_token:
raise RuntimeError("[Sub2API] 管理员登录成功但未返回 access_token")
return access_token
def _list_openai_oauth_accounts(token: str) -> list[dict]:
items = []
page = 1
while True:
data = _request(
"GET",
"/admin/accounts",
token=token,
label="获取账号列表",
params={
"page": page,
"page_size": _PAGE_SIZE,
"platform": "openai",
"type": "oauth",
"sort_by": "id",
"sort_order": "asc",
},
)
page_items = data.get("items") if isinstance(data, dict) else None
if not isinstance(page_items, list):
break
items.extend(page_items)
total = int(data.get("total") or 0) if isinstance(data, dict) else 0
if not page_items or len(items) >= total:
break
page += 1
return items
def _is_managed_account(item: dict, *, kind: str | None = None) -> bool:
extra = item.get("extra") or {}
if not isinstance(extra, dict):
return False
if extra.get(_EXTRA_SOURCE) != "autoteam" and not extra.get(_EXTRA_MANAGED):
return False
if kind and extra.get(_EXTRA_KIND) != kind:
return False
return True
def _managed_email(item: dict) -> str:
extra = item.get("extra") or {}
credentials = item.get("credentials") or {}
return (extra.get(_EXTRA_EMAIL) or credentials.get("email") or item.get("name") or "").strip().lower()
def _managed_auth_file(item: dict) -> str:
extra = item.get("extra") or {}
return (extra.get(_EXTRA_AUTH_FILE) or "").strip()
def _managed_group_ids(item: dict) -> list[int]:
extra = item.get("extra") or {}
values = extra.get(_EXTRA_GROUP_IDS)
if not isinstance(values, list):
return []
result = []
seen = set()
for value in values:
try:
group_id = int(value)
except (TypeError, ValueError):
continue
if group_id <= 0 or group_id in seen:
continue
seen.add(group_id)
result.append(group_id)
return result
def _remote_auth_file_name(local_name: str) -> str:
value = str(local_name or "").strip()
if not value:
return ""
if value.startswith(_REMOTE_AUTH_FILE_PREFIX):
return value
return f"{_REMOTE_AUTH_FILE_PREFIX}{value}"
def _remote_auth_file_candidates(names: list[str] | None) -> set[str]:
candidates = set()
for name in names or []:
value = str(name or "").strip()
if not value:
continue
candidates.add(value)
candidates.add(_remote_auth_file_name(value))
return candidates
def _split_group_spec(value: str | None) -> list[str]:
text = str(value or "").replace(",", ",")
parts = []
seen = set()
for raw in text.replace("\n", ",").split(","):
item = raw.strip()
if not item:
continue
lowered = item.lower()
if lowered in seen:
continue
seen.add(lowered)
parts.append(item)
return parts
def _split_csv_spec(value: str | None) -> list[str]:
text = str(value or "").replace(",", ",")
parts = []
seen = set()
for raw in text.replace("\n", ",").split(","):
item = raw.strip()
if not item:
continue
lowered = item.lower()
if lowered in seen:
continue
seen.add(lowered)
parts.append(item)
return parts
def _build_managed_model_mapping(model_whitelist: str | None = None) -> dict[str, str] | None:
models = _split_csv_spec(model_whitelist if model_whitelist is not None else SUB2API_MODEL_WHITELIST)
if not models:
return None
return {model: model for model in models}
def _build_account_settings() -> dict:
return {
"concurrency": SUB2API_CONCURRENCY,
"priority": SUB2API_PRIORITY,
"rate_multiplier": SUB2API_RATE_MULTIPLIER,
"auto_pause_on_expired": SUB2API_AUTO_PAUSE_ON_EXPIRED,
}
def _apply_managed_credentials_settings(credentials: dict, *, model_whitelist: str | None = None) -> dict:
model_mapping = _build_managed_model_mapping(model_whitelist)
if model_mapping:
credentials["model_mapping"] = model_mapping
return credentials
def _apply_managed_extra_settings(extra: dict) -> dict:
extra["openai_oauth_responses_websockets_v2_mode"] = SUB2API_OPENAI_WS_MODE
extra["openai_oauth_responses_websockets_v2_enabled"] = SUB2API_OPENAI_WS_MODE != "off"
if SUB2API_OPENAI_PASSTHROUGH:
extra["openai_passthrough"] = True
else:
extra.pop("openai_passthrough", None)
extra.pop("openai_oauth_passthrough", None)
return extra
def _dedupe_managed_accounts(token: str, items: list[dict], *, kind: str) -> tuple[dict[str, dict], int]:
deduped: dict[str, dict] = {}
duplicates_deleted = 0
for item in items:
if not _is_managed_account(item, kind=kind):
continue
key = _managed_email(item)
if not key:
key = f"{kind}:{item.get('id')}"
previous = deduped.get(key)
if previous is None or int(item.get("id") or 0) > int(previous.get("id") or 0):
if previous is not None:
_delete_account(token, previous, label="删除重复账号")
duplicates_deleted += 1
deduped[key] = item
else:
_delete_account(token, item, label="删除重复账号")
duplicates_deleted += 1
return deduped, duplicates_deleted
def _select_managed_account_by_email(items: list[dict], email: str, *, kind: str) -> tuple[dict | None, int]:
matches = [
item
for item in items
if _is_managed_account(item, kind=kind) and _managed_email(item) == email.lower()
]
if not matches:
return None, 0
matches.sort(key=lambda item: int(item.get("id") or 0), reverse=True)
return matches[0], max(0, len(matches) - 1)
def _parse_jwt_payload(token: str) -> dict:
parts = (token or "").split(".")
if len(parts) < 2:
return {}
payload = parts[1]
payload += "=" * (-len(payload) % 4)
try:
return json.loads(base64.urlsafe_b64decode(payload))
except Exception:
return {}
def _list_openai_groups(token: str) -> list[dict]:
data = _request(
"GET",
"/admin/groups/all",
token=token,
label="获取 Sub2API 分组列表",
params={"platform": "openai"},
)
return data if isinstance(data, list) else []
def _get_group_by_id(token: str, group_id: int) -> dict | None:
try:
data = _request(
"GET",
f"/admin/groups/{group_id}",
token=token,
label=f"获取 Sub2API 分组 {group_id}",
)
except Exception:
return None
return data if isinstance(data, dict) else None
def _resolve_group_binding(token: str, group_spec: str | None = None) -> tuple[list[int], list[str]]:
parts = _split_group_spec(group_spec if group_spec is not None else SUB2API_GROUP)
if not parts:
return [], []
groups = _list_openai_groups(token)
by_id = {}
by_name = {}
for item in groups:
if not isinstance(item, dict):
continue
try:
group_id = int(item.get("id") or 0)
except (TypeError, ValueError):
continue
if group_id <= 0:
continue
by_id[group_id] = item
name = str(item.get("name") or "").strip()
if name:
by_name[name.lower()] = item
resolved_ids = []
resolved_names = []
seen_ids = set()
for part in parts:
group = None
if part.isdigit():
group = by_id.get(int(part)) or _get_group_by_id(token, int(part))
else:
group = by_name.get(part.lower())
if not isinstance(group, dict):
raise RuntimeError(f"[Sub2API] 未找到分组: {part}")
platform = str(group.get("platform") or "").strip().lower()
if platform and platform != "openai":
raise RuntimeError(f"[Sub2API] 分组 {part} 不是 openai 平台,当前平台: {platform}")
try:
group_id = int(group.get("id") or 0)
except (TypeError, ValueError):
group_id = 0
if group_id <= 0:
raise RuntimeError(f"[Sub2API] 分组 {part} 缺少有效 ID")
if group_id in seen_ids:
continue
seen_ids.add(group_id)
resolved_ids.append(group_id)
resolved_names.append(str(group.get("name") or group_id))
return resolved_ids, resolved_names
def _list_proxies(token: str) -> list[dict]:
data = _request(
"GET",
"/admin/proxies/all",
token=token,
label="获取 Sub2API 代理列表",
)
return data if isinstance(data, list) else []
def _resolve_proxy_id(token: str, proxy_spec: str | None = None) -> int | None:
spec = str(proxy_spec if proxy_spec is not None else SUB2API_PROXY or "").strip()
if not spec:
return None
if spec.lstrip("+-").isdigit():
proxy_id = int(spec)
if proxy_id <= 0:
raise RuntimeError(f"[Sub2API] 代理 ID 必须是正整数: {spec}")
return proxy_id
proxies = _list_proxies(token)
matches = []
for item in proxies:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if name.lower() == spec.lower():
matches.append(item)
if not matches:
raise RuntimeError(f"[Sub2API] 未找到代理: {spec}")
if len(matches) > 1:
raise RuntimeError(f"[Sub2API] 找到多个同名代理: {spec}")
try:
proxy_id = int(matches[0].get("id") or 0)
except (TypeError, ValueError):
proxy_id = 0
if proxy_id <= 0:
raise RuntimeError(f"[Sub2API] 代理 {spec} 缺少有效 ID")
return proxy_id
def _extract_organization_id(auth_claims: dict) -> str:
organizations = auth_claims.get("organizations")
if not isinstance(organizations, list):
return ""
for item in organizations:
if not isinstance(item, dict):
continue
if item.get("is_default") and item.get("id"):
return str(item["id"])
for item in organizations:
if isinstance(item, dict) and item.get("id"):
return str(item["id"])
return ""
def _parse_timestamp(value, *, default: int | None = None) -> int | None:
if isinstance(value, (int, float)):
return int(value)
text = str(value or "").strip()
if not text:
return default
try:
return int(float(text))
except Exception:
pass
try:
if text.endswith("Z"):
return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp())
return int(datetime.fromisoformat(text).timestamp())
except Exception:
return default
def _to_local_iso(ts: int | None) -> str:
if not ts:
return ""
return datetime.fromtimestamp(int(ts), timezone.utc).astimezone().isoformat(timespec="seconds")
def _quota_extra_fields(quota_info: dict | None, *, now_ts: int | None = None) -> dict:
if not isinstance(quota_info, dict):
return {}
now_ts = int(now_ts or time.time())
primary_pct = int(quota_info.get("primary_pct", 0) or 0)
weekly_pct = int(quota_info.get("weekly_pct", 0) or 0)
primary_resets_at = _parse_timestamp(quota_info.get("primary_resets_at"))
weekly_resets_at = _parse_timestamp(quota_info.get("weekly_resets_at"))
extra = {
"codex_5h_used_percent": primary_pct,
"codex_5h_window_minutes": 300,
"codex_7d_used_percent": weekly_pct,
"codex_7d_window_minutes": 10080,
"codex_primary_used_percent": primary_pct,
"codex_primary_window_minutes": 300,
"codex_secondary_used_percent": weekly_pct,
"codex_secondary_window_minutes": 10080,
"codex_primary_over_secondary_percent": 0,
"codex_usage_updated_at": _to_local_iso(now_ts),
}
if primary_resets_at:
primary_after = max(0, primary_resets_at - now_ts)
extra.update(
{
"codex_5h_reset_after_seconds": primary_after,
"codex_5h_reset_at": _to_local_iso(primary_resets_at),
"codex_primary_reset_after_seconds": primary_after,
}
)
if weekly_resets_at:
weekly_after = max(0, weekly_resets_at - now_ts)
extra.update(
{
"codex_7d_reset_after_seconds": weekly_after,
"codex_7d_reset_at": _to_local_iso(weekly_resets_at),
"codex_secondary_reset_after_seconds": weekly_after,
}
)
return extra
def _load_auth_data(path: Path) -> dict:
return json.loads(read_text(path))
def _build_credentials(auth_data: dict) -> dict:
id_token = auth_data.get("id_token", "")
claims = _parse_jwt_payload(id_token) if id_token else {}
auth_claims = claims.get("https://api.openai.com/auth", {}) if isinstance(claims, dict) else {}
credentials = {"access_token": auth_data.get("access_token", "")}
expires_at = _parse_timestamp(auth_data.get("expired"), default=int(time.time()) + 3600)
if expires_at:
credentials["expires_at"] = expires_at
refresh_token = auth_data.get("refresh_token", "")
if refresh_token:
credentials["refresh_token"] = refresh_token
if id_token:
credentials["id_token"] = id_token
client_id = auth_data.get("client_id") or claims.get("aud", [""])[0] if isinstance(claims.get("aud"), list) else ""
client_id = client_id or CODEX_CLIENT_ID
if client_id:
credentials["client_id"] = client_id
email = auth_data.get("email") or claims.get("email") or ""
if email:
credentials["email"] = email
account_id = auth_data.get("account_id") or auth_claims.get("chatgpt_account_id") or ""
if account_id:
credentials["chatgpt_account_id"] = account_id
user_id = auth_claims.get("chatgpt_user_id") or ""
if user_id:
credentials["chatgpt_user_id"] = user_id
organization_id = auth_claims.get("poid") or _extract_organization_id(auth_claims)
if organization_id:
credentials["organization_id"] = organization_id
plan_type = auth_claims.get("chatgpt_plan_type") or ""
if plan_type:
credentials["plan_type"] = plan_type
subscription_expires_at = auth_claims.get("chatgpt_subscription_active_until") or ""
if subscription_expires_at:
credentials["subscription_expires_at"] = subscription_expires_at
return credentials
def _build_extra(email: str, auth_file_name: str, *, kind: str, quota_info: dict | None = None) -> dict:
extra = {
_EXTRA_MANAGED: True,
_EXTRA_KIND: kind,
_EXTRA_EMAIL: email.lower(),
_EXTRA_AUTH_FILE: _remote_auth_file_name(auth_file_name),
_EXTRA_SOURCE: "autoteam",
_EXTRA_LAST_SYNC_AT: int(time.time()),
"email": email.lower(),
}
extra.update(_quota_extra_fields(quota_info))
return extra
def _attach_group_metadata(extra: dict, group_ids: list[int] | None, group_names: list[str] | None) -> dict:
extra[_EXTRA_GROUP_IDS] = [int(value) for value in group_ids or []]
extra[_EXTRA_GROUP_NAMES] = [str(value) for value in group_names or [] if str(value).strip()]
return extra
def _account_group_ids(account: dict) -> list[int]:
result = []
seen = set()
for value in account.get("group_ids") or []:
try:
group_id = int(value)
except (TypeError, ValueError):
continue
if group_id <= 0 or group_id in seen:
continue
seen.add(group_id)
result.append(group_id)
for item in account.get("groups") or []:
if not isinstance(item, dict):
continue
try:
group_id = int(item.get("id") or 0)
except (TypeError, ValueError):
continue
if group_id <= 0 or group_id in seen:
continue
seen.add(group_id)
result.append(group_id)
return result
def _merge_group_ids(account: dict, desired_group_ids: list[int] | None) -> list[int]:
desired = set()
for value in desired_group_ids or []:
try:
group_id = int(value)
except (TypeError, ValueError):
continue
if group_id > 0:
desired.add(group_id)
existing = set(_account_group_ids(account))
previous_managed = set(_managed_group_ids(account))
merged = (existing - previous_managed) | desired
return sorted(merged)
def _create_account(
token: str,
*,
name: str,
credentials: dict,
extra: dict,
label: str,
group_ids: list[int] | None = None,
account_settings: dict | None = None,
proxy_id: int | None = None,
) -> dict:
payload = {
"name": name,
"platform": "openai",
"type": "oauth",
"credentials": credentials,
"extra": extra,
**_build_account_settings(),
"group_ids": list(group_ids or []),
}
if proxy_id is not None:
payload["proxy_id"] = int(proxy_id)
if account_settings:
payload.update(account_settings)
return _request(
"POST",
"/admin/accounts",
token=token,
label=label,
json=payload,
)
def _update_account(
token: str,
account: dict,
*,
credentials: dict,
extra: dict,
name: str | None = None,
status: str | None = None,
group_ids: list[int] | None = None,
account_settings: dict | None = None,
):
payload = {"credentials": credentials, "extra": extra}
if name:
payload["name"] = name
if status:
payload["status"] = status
if group_ids is not None:
payload["group_ids"] = list(group_ids)
if account_settings:
payload.update(account_settings)
return _request(
"PUT",
f"/admin/accounts/{account['id']}",
token=token,
label=f"更新账号 {name or _managed_email(account) or account.get('id')}",
json=payload,
)
def _upsert_managed_pool_account(
token: str,
*,
target: dict,
existing: dict | None,
group_ids: list[int],
group_names: list[str],
overwrite_account_settings: bool,
proxy_id: int | None = None,
) -> dict:
email = target["email"]
desired_credentials = _build_credentials(target["auth_data"])
desired_extra = _build_extra(
email,
target["auth_path"].name,
kind=_KIND_POOL,
quota_info=target.get("quota_info"),
)
_attach_group_metadata(desired_extra, group_ids, group_names)
if existing:
merged_credentials = dict(existing.get("credentials") or {})
merged_credentials.update(desired_credentials)
merged_extra = dict(existing.get("extra") or {})
merged_extra.update(desired_extra)
account_settings = None
if overwrite_account_settings:
account_settings = _build_account_settings()
_apply_managed_credentials_settings(merged_credentials)
_apply_managed_extra_settings(merged_extra)
_update_account(
token,
existing,
credentials=merged_credentials,
extra=merged_extra,
status="active" if existing.get("status") != "active" else None,
group_ids=_merge_group_ids(existing, group_ids),
account_settings=account_settings,
)
logger.info("[Sub2API] 更新: %s", email)
return {"action": "updated", "account_id": existing.get("id")}
_apply_managed_credentials_settings(desired_credentials)
_apply_managed_extra_settings(desired_extra)
created = _create_account(
token,
name=target["name"],
credentials=desired_credentials,
extra=desired_extra,
label=f"创建账号 {email}",
group_ids=group_ids,
account_settings=_build_account_settings(),
proxy_id=proxy_id,
)
logger.info("[Sub2API] 创建: %s", email)
account_id = created.get("id") if isinstance(created, dict) else None
return {"action": "created", "account_id": account_id}
def _delete_account(token: str, account: dict, *, label: str = "删除账号") -> bool:
_request(
"DELETE",
f"/admin/accounts/{account['id']}",
token=token,
label=f"{label} {account.get('name') or account.get('id')}",
)
return True
def verify_sub2api_connection() -> bool:
try:
token = _login()
accounts = _list_openai_oauth_accounts(token)
group_ids, group_names = _resolve_group_binding(token)
if group_ids:
logger.info(
"[验证] Sub2API 连接成功(当前 %d 个 OpenAI OAuth 账号,分组: %s)",
len(accounts),
", ".join(f"{name}#{group_id}" for group_id, name in zip(group_ids, group_names)),
)
else:
logger.info("[验证] Sub2API 连接成功(当前 %d 个 OpenAI OAuth 账号)", len(accounts))
return True
except Exception as exc:
logger.error("[验证] Sub2API 连接失败: %s", exc)
return False
def sync_to_sub2api():
from autoteam.accounts import STATUS_ACTIVE, is_account_disabled, load_accounts
accounts = load_accounts()
local_emails = {str(acc.get("email") or "").lower() for acc in accounts if acc.get("email")}
active_targets = {}
for acc in accounts:
if is_account_disabled(acc):
continue
if acc.get("status") != STATUS_ACTIVE or not acc.get("auth_file"):
continue
auth_path = Path(acc["auth_file"])
if not auth_path.exists():
continue
try:
auth_data = _load_auth_data(auth_path)
except Exception as exc:
logger.warning("[Sub2API] 读取 auth 文件失败,跳过 %s: %s", auth_path, exc)
continue
email = (auth_data.get("email") or acc.get("email") or "").strip().lower()
if not email:
continue
active_targets[email] = {
"email": email,
"name": acc.get("email") or email,
"auth_path": auth_path,
"auth_data": auth_data,
"quota_info": acc.get("last_quota"),
}
token = _login()
group_ids, group_names = _resolve_group_binding(token)
remote_accounts = _list_openai_oauth_accounts(token)
existing_by_email, duplicates_deleted = _dedupe_managed_accounts(token, remote_accounts, kind=_KIND_POOL)
logger.info(
"[Sub2API] active 账号: %d, Sub2API 管理账号: %d",
len(active_targets),
len(existing_by_email),
)
if group_ids:
logger.info(
"[Sub2API] 目标分组: %s", ", ".join(f"{name}#{group_id}" for group_id, name in zip(group_ids, group_names))
)
created = 0
updated = 0
deleted = 0
overwrite_account_settings = SUB2API_OVERWRITE_ACCOUNT_SETTINGS
proxy_id = None
proxy_id_resolved = False
for email, target in active_targets.items():
existing = existing_by_email.get(email)
if not existing and not proxy_id_resolved:
proxy_id = _resolve_proxy_id(token)
proxy_id_resolved = True
result = _upsert_managed_pool_account(
token,
target=target,
existing=existing,
group_ids=group_ids,
group_names=group_names,
overwrite_account_settings=overwrite_account_settings,
proxy_id=proxy_id,
)
if result["action"] == "created":
created += 1
else:
updated += 1
for email, account in existing_by_email.items():
if email in local_emails and email not in active_targets:
_delete_account(token, account, label="删除非 active 账号")
logger.info("[Sub2API] 删除非 active 账号: %s", email)
deleted += 1
final_accounts = _list_openai_oauth_accounts(token)
final_managed = [item for item in final_accounts if _is_managed_account(item, kind=_KIND_POOL)]
logger.info(
"[Sub2API] 同步完成: 创建 %d, 更新 %d, 删除 %d, 远端去重 %d",
created,
updated,
deleted,
duplicates_deleted,
)
logger.info("[Sub2API] Sub2API 中本地管理: %d, 本地 active: %d", len(final_managed), len(active_targets))
return {
"created": created,
"updated": updated,
"deleted": deleted,
"remote_duplicates_deleted": duplicates_deleted,
}
def sync_account_to_sub2api(email: str, filepath: str, *, quota_info: dict | None = None):
auth_path = Path(filepath)
if not auth_path.exists():
raise FileNotFoundError(f"账号认证文件不存在: {auth_path}")
auth_data = _load_auth_data(auth_path)
email_hint = (email or "").strip().lower()
auth_email = (auth_data.get("email") or "").strip().lower()
target_email = auth_email or email_hint
if not target_email:
raise RuntimeError("[Sub2API] 账号认证文件中未找到邮箱")
if email_hint and auth_email and email_hint != auth_email:
logger.warning("[Sub2API] 认证文件邮箱与请求邮箱不一致,按认证文件邮箱同步: %s -> %s", email_hint, auth_email)
token = _login()
group_ids, group_names = _resolve_group_binding(token)
remote_accounts = _list_openai_oauth_accounts(token)
existing, duplicates_seen = _select_managed_account_by_email(remote_accounts, target_email, kind=_KIND_POOL)
proxy_id = None if existing else _resolve_proxy_id(token)
target = {
"email": target_email,
"name": target_email,
"auth_path": auth_path,
"auth_data": auth_data,
"quota_info": quota_info,
}
result = _upsert_managed_pool_account(
token,
target=target,
existing=existing,
group_ids=group_ids,
group_names=group_names,
overwrite_account_settings=SUB2API_OVERWRITE_ACCOUNT_SETTINGS,
proxy_id=proxy_id,
)
remote_auth_name = _remote_auth_file_name(auth_path.name)
logger.info(
"[Sub2API] 单凭证同步完成: %s (%s, account_id=%s, duplicates_seen=%d)",
remote_auth_name,
result["action"],
result.get("account_id"),
duplicates_seen,
)
return {
"uploaded": remote_auth_name,
"action": result["action"],
"account_id": result.get("account_id"),
"remote_duplicates_seen": duplicates_seen,
}
def sync_main_codex_to_sub2api(filepath):
auth_path = Path(filepath)
if not auth_path.exists():
raise FileNotFoundError(f"主号认证文件不存在: {auth_path}")
auth_data = _load_auth_data(auth_path)
email = (auth_data.get("email") or "").strip().lower()
token = _login()
group_ids, group_names = _resolve_group_binding(token)
remote_accounts = _list_openai_oauth_accounts(token)
existing_by_email, duplicates_deleted = _dedupe_managed_accounts(token, remote_accounts, kind=_KIND_MAIN)
desired_credentials = _build_credentials(auth_data)
desired_extra = _build_extra(email, auth_path.name, kind=_KIND_MAIN)
_attach_group_metadata(desired_extra, group_ids, group_names)
name = f"AutoTeam Main | {email}" if email else "AutoTeam Main"
overwrite_account_settings = SUB2API_OVERWRITE_ACCOUNT_SETTINGS
current = existing_by_email.get(email) if email else None
if current:
merged_credentials = dict(current.get("credentials") or {})
merged_credentials.update(desired_credentials)
merged_extra = dict(current.get("extra") or {})
merged_extra.update(desired_extra)
account_settings = None
if overwrite_account_settings:
account_settings = _build_account_settings()
_apply_managed_credentials_settings(merged_credentials)
_apply_managed_extra_settings(merged_extra)
_update_account(
token,
current,
credentials=merged_credentials,
extra=merged_extra,
name=name,
status="active",
group_ids=_merge_group_ids(current, group_ids),
account_settings=account_settings,
)
account_id = current.get("id")
else:
_apply_managed_credentials_settings(desired_credentials)
_apply_managed_extra_settings(desired_extra)
created = _create_account(
token,
name=name,
credentials=desired_credentials,
extra=desired_extra,
label="创建主号账号",
group_ids=group_ids,
account_settings=_build_account_settings(),
)
account_id = created.get("id") if isinstance(created, dict) else None
deleted = []
for item in existing_by_email.values():
if current and item.get("id") == current.get("id"):
continue
if email and _managed_email(item) == email:
continue
_delete_account(token, item, label="删除旧主号账号")
deleted.append(item.get("id"))
remote_auth_name = _remote_auth_file_name(auth_path.name)
logger.info(
"[Sub2API] 主号 Codex 已同步: %s (account_id=%s, duplicates=%d, deleted_old=%d)",
remote_auth_name,
account_id,
duplicates_deleted,
len(deleted),
)
return {"uploaded": remote_auth_name, "account_id": account_id, "deleted_old": deleted}
def delete_main_codex_from_sub2api():
token = _login()
remote_accounts = _list_openai_oauth_accounts(token)
deleted = []
for item in remote_accounts:
if not _is_managed_account(item, kind=_KIND_MAIN):
continue
_delete_account(token, item, label="删除主号账号")
deleted.append(item.get("name") or str(item.get("id")))
return {"deleted": deleted, "count": len(deleted)}
def delete_account_from_sub2api(email: str, *, auth_names: list[str] | None = None):
token = _login()
remote_accounts = _list_openai_oauth_accounts(token)
auth_name_set = _remote_auth_file_candidates(auth_names)
deleted = []
for item in remote_accounts:
if not _is_managed_account(item, kind=_KIND_POOL):
continue
item_email = _managed_email(item)
item_auth_name = _managed_auth_file(item)
if item_email != email.lower() and item_auth_name not in auth_name_set:
continue
_delete_account(token, item, label="删除账号")
deleted.append(item.get("name") or str(item.get("id")))
return {"deleted": deleted, "count": len(deleted)}