mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(oauth): 新增 Codex account_user_id 和 organizations 字段采集、展示与判重
- 从 Codex id_token claims 和 token_response 中提取 account_user_id 和 organizations - OAuth 判重逻辑改为优先按 account_user_id 匹配,支持同用户不同 Team 不误判 - 号池和 Provider 详情页展示组织标签、account ID 和 account_user_id - 前端重复的 OAuth identity 工具函数提取到 utils/oauthIdentity.ts - 后端重复的 normalize_oauth_organizations 提取到 core/provider_oauth_utils.py
This commit is contained in:
@@ -29,6 +29,7 @@ from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.core.provider_oauth_utils import normalize_oauth_organizations
|
||||
from src.database import get_db
|
||||
from src.models.database import Provider, ProviderAPIKey
|
||||
from src.services.billing.precision import to_money_decimal
|
||||
@@ -355,6 +356,32 @@ def _derive_oauth_plan_type(
|
||||
return None
|
||||
|
||||
|
||||
def _derive_oauth_account_id(auth_config: dict[str, Any] | None = None) -> str | None:
|
||||
if not isinstance(auth_config, dict):
|
||||
return None
|
||||
raw = auth_config.get("account_id")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
normalized = raw.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _derive_oauth_account_user_id(auth_config: dict[str, Any] | None = None) -> str | None:
|
||||
if not isinstance(auth_config, dict):
|
||||
return None
|
||||
raw = auth_config.get("account_user_id")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
normalized = raw.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _derive_oauth_organizations(auth_config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
if not isinstance(auth_config, dict):
|
||||
return []
|
||||
return normalize_oauth_organizations(auth_config.get("organizations"))
|
||||
|
||||
|
||||
def _compute_health_aggregate(
|
||||
health_by_format: Any, circuit_breaker_by_format: Any
|
||||
) -> tuple[float, bool]:
|
||||
@@ -1063,6 +1090,9 @@ async def _serialize_pool_key_details(
|
||||
oauth_plan_type=_derive_oauth_plan_type(
|
||||
k, provider_type, auth_config=oauth_auth_config
|
||||
),
|
||||
oauth_account_id=_derive_oauth_account_id(oauth_auth_config),
|
||||
oauth_account_user_id=_derive_oauth_account_user_id(oauth_auth_config),
|
||||
oauth_organizations=_derive_oauth_organizations(oauth_auth_config),
|
||||
quota_updated_at=_extract_quota_updated_at(
|
||||
provider_type,
|
||||
getattr(k, "upstream_metadata", None),
|
||||
|
||||
@@ -66,6 +66,13 @@ class PoolSchedulingReason(BaseModel):
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class OAuthOrganizationSummary(BaseModel):
|
||||
id: str | None = None
|
||||
title: str | None = None
|
||||
is_default: bool = False
|
||||
role: str | None = None
|
||||
|
||||
|
||||
class PoolKeyDetail(BaseModel):
|
||||
"""Detailed status of a single pool key."""
|
||||
|
||||
@@ -77,6 +84,9 @@ class PoolKeyDetail(BaseModel):
|
||||
oauth_invalid_at: int | None = None
|
||||
oauth_invalid_reason: str | None = None
|
||||
oauth_plan_type: str | None = None
|
||||
oauth_account_id: str | None = None
|
||||
oauth_account_user_id: str | None = None
|
||||
oauth_organizations: list[OAuthOrganizationSummary] = Field(default_factory=list)
|
||||
quota_updated_at: int | None = None
|
||||
# 健康度聚合字段(与 Provider Key 列表口径一致)
|
||||
health_score: float = 1.0
|
||||
|
||||
@@ -500,6 +500,60 @@ def _normalize_codex_plan_group(plan_type: Any) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_codex_identity_value(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _is_codex_provider(provider_type: Any) -> bool:
|
||||
return str(provider_type or "").strip().lower() == ProviderType.CODEX.value
|
||||
|
||||
|
||||
def _match_codex_identity(
|
||||
*,
|
||||
new_auth_config: dict[str, Any],
|
||||
existing_auth_config: dict[str, Any],
|
||||
) -> bool | None:
|
||||
"""Codex 判重优先按 account/team 维度进行。
|
||||
|
||||
Returns:
|
||||
True: 明确重复
|
||||
False: 明确不是重复(例如同用户不同 account/team)
|
||||
None: 信息不足,调用方应继续使用兜底规则
|
||||
"""
|
||||
new_provider_type = new_auth_config.get("provider_type")
|
||||
existing_provider_type = existing_auth_config.get("provider_type")
|
||||
if not (_is_codex_provider(new_provider_type) or _is_codex_provider(existing_provider_type)):
|
||||
return None
|
||||
|
||||
new_account_user_id = _normalize_codex_identity_value(new_auth_config.get("account_user_id"))
|
||||
existing_account_user_id = _normalize_codex_identity_value(
|
||||
existing_auth_config.get("account_user_id")
|
||||
)
|
||||
if new_account_user_id and existing_account_user_id:
|
||||
return new_account_user_id == existing_account_user_id
|
||||
|
||||
new_account_id = _normalize_codex_identity_value(new_auth_config.get("account_id"))
|
||||
existing_account_id = _normalize_codex_identity_value(existing_auth_config.get("account_id"))
|
||||
new_user_id = _normalize_codex_identity_value(new_auth_config.get("user_id"))
|
||||
existing_user_id = _normalize_codex_identity_value(existing_auth_config.get("user_id"))
|
||||
new_email = _normalize_codex_identity_value(new_auth_config.get("email"))
|
||||
existing_email = _normalize_codex_identity_value(existing_auth_config.get("email"))
|
||||
|
||||
if new_account_id and existing_account_id and new_account_id != existing_account_id:
|
||||
return False
|
||||
|
||||
if new_account_id and existing_account_id and new_user_id and existing_user_id:
|
||||
return new_account_id == existing_account_id and new_user_id == existing_user_id
|
||||
|
||||
if new_account_id and existing_account_id and new_email and existing_email:
|
||||
return new_account_id == existing_account_id and new_email == existing_email
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_codex_cross_plan_group_non_duplicate(
|
||||
*,
|
||||
new_provider_type: Any,
|
||||
@@ -528,8 +582,9 @@ def _check_duplicate_oauth_account(
|
||||
检查是否存在重复的 OAuth 账号。
|
||||
|
||||
通过以下字段判断重复:
|
||||
- user_id: Codex 等使用用户级别 ID(同 team 下不同成员共享 account_id 但 user_id 不同)
|
||||
对 Codex 额外按账号类型分组:free 与 Team/Plus/Enterprise 互不判重
|
||||
- Codex: 优先 account_user_id,其次 (user_id, account_id) / (email, account_id)
|
||||
同一用户切换不同 Team/account_id 时不判重;free 与 Team/Plus/Enterprise 互不判重
|
||||
- user_id: Codex 之外优先使用用户级别 ID
|
||||
- email + auth_method: Kiro 使用 email + auth_method 组合判断
|
||||
(同一邮箱可能通过 Social 和 IdC 两种方式登录,视为不同账号)
|
||||
- email: 其他 OAuth Provider 使用邮箱判断
|
||||
@@ -576,8 +631,22 @@ def _check_duplicate_oauth_account(
|
||||
|
||||
is_duplicate = False
|
||||
|
||||
codex_identity_match = _match_codex_identity(
|
||||
new_auth_config=auth_config,
|
||||
existing_auth_config=decrypted_config,
|
||||
)
|
||||
if codex_identity_match is True:
|
||||
is_duplicate = True
|
||||
elif codex_identity_match is False:
|
||||
is_duplicate = False
|
||||
|
||||
# user_id 相同即重复(Codex 等,同一 team 下不同成员共享 account_id 但 user_id 不同)
|
||||
if new_user_id and existing_user_id and new_user_id == existing_user_id:
|
||||
if (
|
||||
codex_identity_match is None
|
||||
and new_user_id
|
||||
and existing_user_id
|
||||
and new_user_id == existing_user_id
|
||||
):
|
||||
if not _is_codex_cross_plan_group_non_duplicate(
|
||||
new_provider_type=new_provider_type,
|
||||
existing_provider_type=existing_provider_type,
|
||||
@@ -587,7 +656,13 @@ def _check_duplicate_oauth_account(
|
||||
is_duplicate = True
|
||||
|
||||
# email 判断
|
||||
if not is_duplicate and new_email and existing_email and new_email == existing_email:
|
||||
if (
|
||||
codex_identity_match is None
|
||||
and not is_duplicate
|
||||
and new_email
|
||||
and existing_email
|
||||
and new_email == existing_email
|
||||
):
|
||||
is_kiro = new_provider_type == "kiro" or existing_provider_type == "kiro"
|
||||
if is_kiro:
|
||||
# Kiro: 只有 email + auth_method 都相同才视为重复
|
||||
@@ -617,7 +692,13 @@ def _check_duplicate_oauth_account(
|
||||
return existing_key
|
||||
|
||||
# 活跃的重复账号,拒绝添加
|
||||
identifier = new_email or new_user_id or ""
|
||||
identifier = (
|
||||
auth_config.get("account_user_id")
|
||||
or auth_config.get("account_id")
|
||||
or new_email
|
||||
or new_user_id
|
||||
or ""
|
||||
)
|
||||
raise InvalidRequestException(
|
||||
f"该 OAuth 账号 ({identifier}) 已存在于当前 Provider 中"
|
||||
f"(名称: {existing_key.name})"
|
||||
@@ -1318,7 +1399,7 @@ def _coerce_import_str(value: Any) -> str | None:
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _extract_standard_oauth_import_entry(item: Any) -> dict[str, str] | None:
|
||||
def _extract_standard_oauth_import_entry(item: Any) -> dict[str, Any] | None:
|
||||
if isinstance(item, str):
|
||||
token = _coerce_import_str(item)
|
||||
if token:
|
||||
@@ -1333,7 +1414,7 @@ def _extract_standard_oauth_import_entry(item: Any) -> dict[str, str] | None:
|
||||
if not refresh_token:
|
||||
return None
|
||||
|
||||
entry: dict[str, str] = {"refresh_token": refresh_token}
|
||||
entry: dict[str, Any] = {"refresh_token": refresh_token}
|
||||
|
||||
account_id = (
|
||||
_coerce_import_str(item.get("account_id"))
|
||||
@@ -1344,6 +1425,15 @@ def _extract_standard_oauth_import_entry(item: Any) -> dict[str, str] | None:
|
||||
if account_id:
|
||||
entry["account_id"] = account_id
|
||||
|
||||
account_user_id = (
|
||||
_coerce_import_str(item.get("account_user_id"))
|
||||
or _coerce_import_str(item.get("accountUserId"))
|
||||
or _coerce_import_str(item.get("chatgpt_account_user_id"))
|
||||
or _coerce_import_str(item.get("chatgptAccountUserId"))
|
||||
)
|
||||
if account_user_id:
|
||||
entry["account_user_id"] = account_user_id
|
||||
|
||||
plan_type = (
|
||||
_coerce_import_str(item.get("plan_type"))
|
||||
or _coerce_import_str(item.get("planType"))
|
||||
@@ -1369,7 +1459,7 @@ def _extract_standard_oauth_import_entry(item: Any) -> dict[str, str] | None:
|
||||
return entry
|
||||
|
||||
|
||||
def _parse_standard_oauth_import_entries(raw_input: str) -> list[dict[str, str]]:
|
||||
def _parse_standard_oauth_import_entries(raw_input: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
解析标准 OAuth 导入输入,保留 refresh_token 及可用账号提示字段。
|
||||
|
||||
@@ -1384,7 +1474,7 @@ def _parse_standard_oauth_import_entries(raw_input: str) -> list[dict[str, str]]
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
result: list[dict[str, str]] = []
|
||||
result: list[dict[str, Any]] = []
|
||||
|
||||
if raw.startswith("["):
|
||||
try:
|
||||
@@ -1668,9 +1758,9 @@ def _commit_batch_import_writes_if_needed(db: Session, pending_writes: int) -> i
|
||||
return 0
|
||||
|
||||
|
||||
def _apply_codex_import_hints(auth_config: dict[str, Any], import_entry: dict[str, str]) -> None:
|
||||
def _apply_codex_import_hints(auth_config: dict[str, Any], import_entry: dict[str, Any]) -> None:
|
||||
"""将导入文件中可用的 Codex 账号信息作为兜底补全(不覆盖已有值)。"""
|
||||
for field in ("account_id", "plan_type", "user_id", "email"):
|
||||
for field in ("account_user_id", "account_id", "plan_type", "user_id", "email"):
|
||||
value = import_entry.get(field)
|
||||
if value and not auth_config.get(field):
|
||||
auth_config[field] = value
|
||||
|
||||
@@ -326,6 +326,21 @@ def _extract_codex_fields_from_claims(claims: dict[str, Any]) -> dict[str, Any]:
|
||||
if account_id:
|
||||
result["account_id"] = account_id
|
||||
|
||||
account_user_id = _first_non_empty_str(
|
||||
[
|
||||
auth.get("chatgpt_account_user_id"),
|
||||
auth.get("chatgptAccountUserId"),
|
||||
auth.get("account_user_id"),
|
||||
auth.get("accountUserId"),
|
||||
claims.get("chatgpt_account_user_id"),
|
||||
claims.get("chatgptAccountUserId"),
|
||||
claims.get("account_user_id"),
|
||||
claims.get("accountUserId"),
|
||||
]
|
||||
)
|
||||
if account_user_id:
|
||||
result["account_user_id"] = account_user_id
|
||||
|
||||
plan_type = _first_non_empty_str(
|
||||
[
|
||||
auth.get("chatgpt_plan_type"),
|
||||
@@ -357,6 +372,10 @@ def _extract_codex_fields_from_claims(claims: dict[str, Any]) -> dict[str, Any]:
|
||||
if user_id:
|
||||
result["user_id"] = user_id
|
||||
|
||||
organizations = auth.get("organizations")
|
||||
if isinstance(organizations, list) and organizations:
|
||||
result["organizations"] = organizations
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -509,3 +528,35 @@ async def enrich_auth_config(
|
||||
if enricher:
|
||||
return await enricher(auth_config, token_response, access_token, proxy_config)
|
||||
return auth_config
|
||||
|
||||
|
||||
def normalize_oauth_organizations(raw: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize raw organizations list from OAuth auth_config into a clean list of dicts."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
normalized: dict[str, Any] = {}
|
||||
org_id = item.get("id")
|
||||
if isinstance(org_id, str) and org_id.strip():
|
||||
normalized["id"] = org_id.strip()
|
||||
|
||||
title = item.get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
normalized["title"] = title.strip()
|
||||
|
||||
role = item.get("role")
|
||||
if isinstance(role, str) and role.strip():
|
||||
normalized["role"] = role.strip()
|
||||
|
||||
if "is_default" in item:
|
||||
normalized["is_default"] = bool(item.get("is_default"))
|
||||
|
||||
if normalized:
|
||||
result.append(normalized)
|
||||
|
||||
return result
|
||||
|
||||
@@ -710,6 +710,13 @@ class EndpointAPIKeyUpdate(BaseModel):
|
||||
return v.strip()
|
||||
|
||||
|
||||
class OAuthOrganizationResponse(BaseModel):
|
||||
id: str | None = Field(default=None, description="OAuth 组织/工作区 ID")
|
||||
title: str | None = Field(default=None, description="OAuth 组织/工作区标题")
|
||||
is_default: bool = Field(default=False, description="是否为默认组织/工作区")
|
||||
role: str | None = Field(default=None, description="当前账号在组织中的角色")
|
||||
|
||||
|
||||
class EndpointAPIKeyResponse(BaseModel):
|
||||
"""Endpoint API Key 响应"""
|
||||
|
||||
@@ -753,6 +760,14 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
default=None, description="OAuth 账号套餐类型(如 free/plus/team/enterprise)"
|
||||
)
|
||||
oauth_account_id: str | None = Field(default=None, description="OAuth 账号 ID")
|
||||
oauth_account_user_id: str | None = Field(
|
||||
default=None,
|
||||
description="OAuth 账号-工作区联合 ID(如 Codex chatgpt_account_user_id)",
|
||||
)
|
||||
oauth_organizations: list[OAuthOrganizationResponse] = Field(
|
||||
default_factory=list,
|
||||
description="OAuth 关联的组织/工作区摘要列表",
|
||||
)
|
||||
oauth_invalid_at: int | None = Field(
|
||||
default=None, description="OAuth Token 失效时间(Unix 时间戳),如账号被封、授权撤销等"
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ async def enrich_codex(
|
||||
access_token: str, # noqa: ARG001
|
||||
proxy_config: dict[str, Any] | None, # noqa: ARG001
|
||||
) -> dict[str, Any]:
|
||||
"""Codex auth_config enrichment: parse id_token -> email/account_id/plan_type/user_id."""
|
||||
"""Codex auth_config enrichment: parse token claims -> account/team identity metadata."""
|
||||
from src.core.provider_oauth_utils import parse_codex_id_token
|
||||
|
||||
def _read_non_empty_str(*values: Any) -> str | None:
|
||||
@@ -100,6 +100,15 @@ async def enrich_codex(
|
||||
if direct_account_id and not auth_config.get("account_id"):
|
||||
auth_config["account_id"] = direct_account_id
|
||||
|
||||
direct_account_user_id = _read_non_empty_str(
|
||||
token_response.get("account_user_id"),
|
||||
token_response.get("accountUserId"),
|
||||
token_response.get("chatgpt_account_user_id"),
|
||||
token_response.get("chatgptAccountUserId"),
|
||||
)
|
||||
if direct_account_user_id and not auth_config.get("account_user_id"):
|
||||
auth_config["account_user_id"] = direct_account_user_id
|
||||
|
||||
direct_plan_type = _read_non_empty_str(
|
||||
token_response.get("plan_type"),
|
||||
token_response.get("planType"),
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.core.provider_oauth_utils import normalize_oauth_organizations
|
||||
from src.models.database import ProviderAPIKey
|
||||
from src.models.endpoint_models import EndpointAPIKeyResponse
|
||||
from src.services.provider_keys.auth_type import normalize_auth_type
|
||||
@@ -47,6 +48,8 @@ def build_key_response(
|
||||
oauth_email = None
|
||||
oauth_plan_type = None
|
||||
oauth_account_id = None
|
||||
oauth_account_user_id = None
|
||||
oauth_organizations: list[dict[str, object]] = []
|
||||
encrypted_auth_config = key_dict.pop("auth_config", None) # 移除敏感字段,避免泄露
|
||||
if auth_type == "oauth" and encrypted_auth_config:
|
||||
try:
|
||||
@@ -61,6 +64,8 @@ def build_key_response(
|
||||
if ag_tier and isinstance(ag_tier, str):
|
||||
oauth_plan_type = ag_tier.lower()
|
||||
oauth_account_id = auth_config.get("account_id") # Codex: chatgpt_account_id
|
||||
oauth_account_user_id = auth_config.get("account_user_id")
|
||||
oauth_organizations = normalize_oauth_organizations(auth_config.get("organizations"))
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e)
|
||||
|
||||
@@ -112,6 +117,8 @@ def build_key_response(
|
||||
"oauth_email": oauth_email,
|
||||
"oauth_plan_type": oauth_plan_type,
|
||||
"oauth_account_id": oauth_account_id,
|
||||
"oauth_account_user_id": oauth_account_user_id,
|
||||
"oauth_organizations": oauth_organizations,
|
||||
"oauth_invalid_at": (
|
||||
int(key.oauth_invalid_at.timestamp()) if key.oauth_invalid_at else None
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user