feat: OAuth 导入导出、提供商筛选、Gemini 图像生成支持与流式处理增强

- OAuth: 支持通过 Refresh Token 导入账号(文件拖拽/粘贴),OAuth Key 可导出为 JSON
- OAuth: 所有 OAuth 端点添加 require_admin 鉴权
- 提供商管理: 新增状态/API格式/模型三级筛选,后端返回 global_model_ids
- Gemini: 新增图像生成模型适配(finalize_provider_request 钩子 + envelope 跳过不兼容字段)
- 流式处理: buffer 残留数据 flush 与 token 兜底估算
- 上游元数据: 提取 merge_upstream_metadata,配额耗尽模型保留与深度合并
- Antigravity 配额: 无 quotaInfo 时视为耗尽,移除 Other 兜底分组
- README: 新增升级备份与回滚指南
This commit is contained in:
fawney19
2026-02-06 21:52:22 +08:00
parent 62dae22a2c
commit 8b6a5d3824
23 changed files with 1129 additions and 101 deletions

View File

@@ -30,6 +30,7 @@ from src.models.endpoint_models import (
EndpointAPIKeyUpdate,
)
from src.services.cache.provider_cache import ProviderCacheService
from src.services.model.upstream_fetcher import merge_upstream_metadata
from src.utils.auth_utils import require_admin
router = APIRouter(tags=["Provider Keys"])
@@ -467,6 +468,29 @@ class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
"无法解密认证配置,可能是加密密钥已更改。请重新添加该密钥。"
)
# OAuth 类型:返回 access_token + refresh_token
if auth_type == "oauth":
try:
decrypted_key = crypto_service.decrypt(key.api_key)
except Exception as e:
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
raise InvalidRequestException(
"无法解密 API Key可能是加密密钥已更改。请重新添加该密钥。"
)
result: dict[str, Any] = {"auth_type": "oauth", "api_key": decrypted_key}
encrypted_auth_config = getattr(key, "auth_config", None)
if encrypted_auth_config:
try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
auth_config = json.loads(decrypted_config)
refresh_token = auth_config.get("refresh_token")
if refresh_token:
result["refresh_token"] = refresh_token
except Exception as e:
logger.error(f"解密 auth_config 失败: ID={self.key_id}, Error={e}")
logger.info(f"[REVEAL] 查看 OAuth Key: ID={self.key_id}, Name={key.name}")
return result
# API Key 类型返回 api_key
try:
decrypted_key = crypto_service.decrypt(key.api_key)
@@ -1178,13 +1202,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
if key.id in metadata_updates:
updates = metadata_updates[key.id]
if isinstance(updates, dict):
# NOTE: upstream_metadata is a plain JSON column (not MutableDict),
# so in-place mutation won't be persisted reliably. Always assign
# a new dict object to mark the column as dirty.
current = key.upstream_metadata
merged: dict = dict(current) if isinstance(current, dict) else {}
merged.update(updates)
key.upstream_metadata = merged
key.upstream_metadata = merge_upstream_metadata(
key.upstream_metadata, updates
)
db.add(key)
# 提交数据库更改

View File

@@ -35,7 +35,8 @@ from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey
from src.models.database import Provider, ProviderAPIKey, User
from src.utils.auth_utils import require_admin
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
@@ -189,7 +190,7 @@ def _parse_callback_params(callback_url: str) -> dict[str, str]:
@router.get("/supported-types")
async def supported_types() -> list[dict[str, Any]]:
async def supported_types(_: User = Depends(require_admin)) -> list[dict[str, Any]]:
# 不返回 client_secret
result: list[dict[str, Any]] = []
for provider_type, template in FIXED_PROVIDERS.items():
@@ -216,6 +217,7 @@ async def start_oauth(
key_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> StartOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
@@ -295,6 +297,7 @@ async def complete_oauth(
payload: CompleteOAuthRequest,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CompleteOAuthResponse:
redis = await get_redis_client(require_redis=True)
assert redis is not None
@@ -428,6 +431,7 @@ async def refresh_oauth(
key_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CompleteOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
@@ -586,6 +590,7 @@ async def start_provider_oauth(
provider_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> StartOAuthResponse:
"""基于 Provider 启动 OAuth不需要预先创建 key"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
@@ -659,6 +664,7 @@ async def complete_provider_oauth(
payload: ProviderCompleteOAuthRequest,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProviderCompleteOAuthResponse:
"""完成 Provider OAuth 并创建 key。"""
redis = await get_redis_client(require_redis=True)
@@ -799,3 +805,155 @@ async def complete_provider_oauth(
has_refresh_token=bool(refresh_token),
email=auth_config.get("email"),
)
# ==============================================================================
# Import Refresh Token (从导出文件导入)
# ==============================================================================
class ImportRefreshTokenRequest(BaseModel):
refresh_token: str = Field(..., min_length=1, description="Refresh Token")
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
@router.post(
"/providers/{provider_id}/import-refresh-token",
response_model=ProviderCompleteOAuthResponse,
)
async def import_refresh_token(
provider_id: str,
payload: ImportRefreshTokenRequest,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProviderCompleteOAuthResponse:
"""通过 Refresh Token 导入 OAuth 账号。
使用导出的 Refresh Token 换取 Access Token 并创建新的 OAuth Key。
"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
# 用 refresh_token 换取 access_token
refresh_token = payload.refresh_token.strip()
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
proxy_config = getattr(provider, "proxy", None)
resp = await post_oauth_token(
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=30.0,
)
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(error_body.get("error_description") or error_body.get("error"))
except Exception:
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
raise InvalidRequestException(f"Refresh Token 验证失败: {error_reason}")
token = resp.json()
access_token = str(token.get("access_token") or "")
new_refresh_token = str(token.get("refresh_token") or "") or refresh_token
expires_in = token.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
if not access_token:
raise InvalidRequestException("token refresh 返回缺少 access_token")
# 构建 auth_config
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token.get("token_type"),
"refresh_token": new_refresh_token or None,
"expires_at": expires_at,
"scope": token.get("scope"),
"updated_at": int(time.time()),
}
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token,
access_token=access_token,
proxy_config=proxy_config,
)
# 确定账号名称
name = (payload.name or "").strip()
if not name:
name = auth_config.get("email") or f"账号_{int(time.time())}"
# 从 Provider 的 endpoints 中提取所有 api_format 作为 Key 的支持格式
api_formats = [ep.api_format for ep in provider.endpoints if ep.api_format and ep.is_active]
# 创建 key
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
new_key = ProviderAPIKeyModel(
provider_id=provider_id,
name=name,
api_key=crypto_service.encrypt(access_token),
auth_type="oauth",
auth_config=crypto_service.encrypt(json.dumps(auth_config)),
api_formats=api_formats,
is_active=True,
)
db.add(new_key)
db.commit()
db.refresh(new_key)
return ProviderCompleteOAuthResponse(
key_id=str(new_key.id),
provider_type=provider_type,
expires_at=expires_at,
has_refresh_token=bool(new_refresh_token),
email=auth_config.get("email"),
)

View File

@@ -240,6 +240,19 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
total_models = model_stats.total or 0
active_models = int(model_stats.active or 0)
# 活跃模型关联的全局模型 ID 列表
global_model_ids = [
row[0]
for row in db.query(Model.global_model_id)
.filter(
Model.provider_id == provider.id,
Model.is_active == True,
Model.global_model_id.isnot(None),
)
.distinct()
.all()
]
api_formats = [e.api_format for e in endpoints]
# 优化: 一次性加载 Provider 的 keys避免 N+1 查询
@@ -328,6 +341,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
active_keys=active_keys,
total_models=total_models,
active_models=active_models,
global_model_ids=global_model_ids,
avg_health_score=avg_health_score,
unhealthy_endpoints=unhealthy_endpoints,
api_formats=api_formats,