Files
Aether/_deprecated_py_src/services/provider/oauth_token.py
fawney19 1d9c77522a refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
2026-04-03 16:26:16 +08:00

214 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Provider OAuth token helpers.
These helpers are for *upstream Provider* OAuth keys (ProviderAPIKey.auth_type == "oauth"),
not for user-login OAuth.
Why:
- Request path uses `get_provider_auth()` which may refresh the access_token lazily.
- Some background/admin paths (model fetch/query, etc.) need the same behavior but must
avoid sharing a SQLAlchemy Session across concurrent async tasks.
Strategy:
- Run `get_provider_auth()` on a detached key-like object (no DB session held during HTTP).
- If refresh updated encrypted fields, persist them back to DB in a short transaction.
"""
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from src.core.logger import logger
from src.database import create_session
from src.models.database import ProviderAPIKey
from src.services.provider.pool.account_state import (
OAUTH_EXPIRED_PREFIX,
OAUTH_REFRESH_FAILED_PREFIX,
)
# ---------------------------------------------------------------------------
# Account-level block 结构化标记
# ---------------------------------------------------------------------------
# oauth_invalid_reason 以此前缀开头的,属于"账号级别"异常(如 Google 要求验证账号);
# 刷新 token 无法修复,必须由用户手动解决后再由管理员手动清除。
# 其余 reason 属于 token 级别异常,成功刷新 token 后自动清除。
OAUTH_ACCOUNT_BLOCK_PREFIX = "[ACCOUNT_BLOCK] "
# 上游返回 "token 已失效" 语义的关键词(小写匹配)。
# 被 codex_refresher 前向分类和 oauth_token 回溯清理共用。
TOKEN_INVALIDATED_KEYWORDS: tuple[str, ...] = (
"authentication token has been invalidated",
"token has been invalidated",
)
# 回溯清理专用:历史写入的中文 reason 也需匹配
_LEGACY_TOKEN_INVALID_KEYWORDS: tuple[str, ...] = (
*TOKEN_INVALIDATED_KEYWORDS,
"codex token 无效或已过期",
)
def looks_like_token_invalidated(message: str | None) -> bool:
"""判断上游错误消息是否表示 access token 已失效/被轮换。"""
lowered = str(message or "").strip().lower()
return any(keyword in lowered for keyword in TOKEN_INVALIDATED_KEYWORDS)
def _is_refresh_recoverable_account_block(reason: str | None) -> bool:
"""历史兼容:部分 token 级异常曾被错误写成 [ACCOUNT_BLOCK]。
这类原因在手动刷新成功后应自动清除,否则前端会继续展示
"Token 失效/账号异常",并阻止 Key 恢复调度。
"""
if not reason:
return False
text = str(reason)
if not text.startswith(OAUTH_ACCOUNT_BLOCK_PREFIX):
return False
lowered = text[len(OAUTH_ACCOUNT_BLOCK_PREFIX) :].strip().lower()
return any(keyword in lowered for keyword in _LEGACY_TOKEN_INVALID_KEYWORDS)
def is_account_level_block(reason: str | None) -> bool:
"""判断 oauth_invalid_reason 是否属于账号级别的 block刷新 token 无法修复)。"""
if not reason:
return False
text = str(reason)
return text.startswith(
OAUTH_ACCOUNT_BLOCK_PREFIX
) and not _is_refresh_recoverable_account_block(text)
async def verify_oauth_before_account_block(
*,
endpoint: Any,
key: Any,
candidate_reason: str,
request_id: str | None = None,
key_display: str | None = None,
) -> bool:
"""Before applying an account-level block, distinguish it from OAuth expiry."""
display = key_display or str(getattr(key, "id", "?") or "?")
try:
from src.services.provider.auth import get_provider_auth
await get_provider_auth(endpoint, key, force_refresh=True, refresh_skew=0)
except Exception as exc:
logger.debug(
"[OAUTH_VERIFY] [{}] {} account-block precheck failed: {}",
request_id,
display,
exc,
)
latest_reason = str(getattr(key, "oauth_invalid_reason", None) or "").strip()
if latest_reason.startswith(OAUTH_EXPIRED_PREFIX) or latest_reason.startswith(
OAUTH_REFRESH_FAILED_PREFIX
):
logger.info(
"[OAUTH_VERIFY] [{}] {} candidate account block ({}) skipped due to {}",
request_id,
display,
candidate_reason,
latest_reason[:120],
)
return False
logger.debug(
"[OAUTH_VERIFY] [{}] {} proceeding with account block ({}), post-refresh reason: {}",
request_id,
display,
candidate_reason,
latest_reason[:120] if latest_reason else "<none>",
)
return True
@dataclass(frozen=True, slots=True)
class OAuthAccessTokenResult:
access_token: str
decrypted_auth_config: dict[str, Any] | None
refreshed: bool
async def resolve_oauth_access_token(
*,
key_id: str,
encrypted_api_key: str,
encrypted_auth_config: str | None,
provider_proxy_config: dict[str, Any] | None = None,
endpoint_api_format: str | None = None,
) -> OAuthAccessTokenResult:
"""Resolve (and lazily refresh) OAuth access_token for a ProviderAPIKey.
This helper is safe to call from concurrent async tasks because it does not
rely on the caller's SQLAlchemy Session:
- It runs refresh logic without an ORM session.
- If refresh succeeded (encrypted fields changed), it persists the new encrypted
values to DB using a short, independent session.
"""
# Local import to avoid circular imports during app startup.
from src.services.provider.auth import get_provider_auth
# Build detached key-like objects for get_provider_auth().
provider_obj = (
SimpleNamespace(proxy=provider_proxy_config) if provider_proxy_config is not None else None
)
endpoint_obj = SimpleNamespace(api_format=str(endpoint_api_format or ""))
key_obj = SimpleNamespace(
id=str(key_id),
auth_type="oauth",
api_key=encrypted_api_key,
auth_config=encrypted_auth_config,
provider=provider_obj,
)
orig_api_key = key_obj.api_key
orig_auth_config = key_obj.auth_config
auth_info = await get_provider_auth(endpoint_obj, key_obj) # type: ignore[arg-type]
if auth_info is None:
# Should not happen for auth_type="oauth", but keep defensive.
return OAuthAccessTokenResult(access_token="", decrypted_auth_config=None, refreshed=False)
access_token = str(auth_info.auth_value or "").removeprefix("Bearer ").strip()
refreshed = (key_obj.api_key != orig_api_key) or (key_obj.auth_config != orig_auth_config)
if refreshed:
# Persist refreshed token/config back to DB.
try:
with create_session() as db:
row = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == str(key_id)).first()
if row is not None:
row.api_key = key_obj.api_key
row.auth_config = key_obj.auth_config
# Refresh succeeded => only clear recoverable token errors.
# True account-level blocks must be cleared explicitly.
current_reason = str(getattr(row, "oauth_invalid_reason", None) or "")
if row.oauth_invalid_at is not None and not is_account_level_block(
current_reason
):
row.oauth_invalid_at = None
row.oauth_invalid_reason = None
db.commit()
except Exception as e:
# Don't fail caller path; token is still usable for this request.
logger.debug("[OAUTH_REFRESH] persist refreshed token failed for key {}: {}", key_id, e)
return OAuthAccessTokenResult(
access_token=access_token,
decrypted_auth_config=auth_info.decrypted_auth_config,
refreshed=refreshed,
)
__all__ = [
"OAuthAccessTokenResult",
"TOKEN_INVALIDATED_KEYWORDS",
"verify_oauth_before_account_block",
"looks_like_token_invalidated",
"resolve_oauth_access_token",
]