mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: Codex 提供商批量刷新限额功能 (#147)
- 后端 (keys.py):新增 POST /providers/{provider_id}/refresh-quota API,向 Codex 提供商的所有活跃 Key 发送测试请求,从响应头解析限额信息并更新数据库
- 前端 (ProviderDetailDrawer.vue):在 Codex 类型提供商的密钥管理区域添加"刷新限额"按钮,带加载动画和结果提示
- 前端 API (keys.ts):新增 refreshProviderQuota 接口调用及 RefreshQuotaResult 类型定义
- 其他:description 字段允许 null,公开 ensure_collectors_registered 函数
This commit is contained in:
@@ -144,3 +144,31 @@ export async function updateProviderKey(
|
|||||||
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 Provider 的所有 Key 限额信息(Codex)
|
||||||
|
*/
|
||||||
|
export interface RefreshQuotaResult {
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
total: number
|
||||||
|
results: Array<{
|
||||||
|
key_id: string
|
||||||
|
key_name: string
|
||||||
|
status: 'success' | 'no_metadata' | 'error'
|
||||||
|
metadata?: {
|
||||||
|
plan_type?: string
|
||||||
|
primary_used_percent?: number
|
||||||
|
primary_reset_seconds?: number
|
||||||
|
secondary_used_percent?: number
|
||||||
|
secondary_reset_seconds?: number
|
||||||
|
}
|
||||||
|
message?: string
|
||||||
|
status_code?: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshProviderQuota(providerId: string): Promise<RefreshQuotaResult> {
|
||||||
|
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function updateProvider(
|
|||||||
data: Partial<{
|
data: Partial<{
|
||||||
name: string
|
name: string
|
||||||
provider_type: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
|
provider_type: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
|
||||||
description: string
|
description: string | null
|
||||||
website: string
|
website: string
|
||||||
provider_priority: number
|
provider_priority: number
|
||||||
billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
|
billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
|
||||||
|
|||||||
@@ -188,6 +188,23 @@
|
|||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ provider.provider_type === 'custom' ? '密钥管理' : '账号管理' }}
|
{{ provider.provider_type === 'custom' ? '密钥管理' : '账号管理' }}
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Codex 刷新限额按钮 -->
|
||||||
|
<Button
|
||||||
|
v-if="provider.provider_type === 'codex' && allKeys.length > 0"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8"
|
||||||
|
:disabled="refreshingQuota"
|
||||||
|
title="刷新所有账号的限额信息"
|
||||||
|
@click="handleRefreshQuota"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
class="w-3.5 h-3.5 mr-1.5"
|
||||||
|
:class="{ 'animate-spin': refreshingQuota }"
|
||||||
|
/>
|
||||||
|
刷新限额
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="endpoints.length > 0"
|
v-if="endpoints.length > 0"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -200,6 +217,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 密钥列表 -->
|
<!-- 密钥列表 -->
|
||||||
<div
|
<div
|
||||||
@@ -667,6 +685,7 @@ import {
|
|||||||
updateProviderKey,
|
updateProviderKey,
|
||||||
revealEndpointKey,
|
revealEndpointKey,
|
||||||
refreshProviderOAuth,
|
refreshProviderOAuth,
|
||||||
|
refreshProviderQuota,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type Model,
|
type Model,
|
||||||
@@ -746,6 +765,9 @@ const prioritySaving = ref(false)
|
|||||||
// OAuth 刷新状态
|
// OAuth 刷新状态
|
||||||
const refreshingOAuthKeyId = ref<string | null>(null)
|
const refreshingOAuthKeyId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Codex 限额刷新状态
|
||||||
|
const refreshingQuota = ref(false)
|
||||||
|
|
||||||
// 描述编辑状态
|
// 描述编辑状态
|
||||||
const editingDescription = ref(false)
|
const editingDescription = ref(false)
|
||||||
const editingDescriptionValue = ref('')
|
const editingDescriptionValue = ref('')
|
||||||
@@ -1041,6 +1063,28 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新 Codex 所有账号限额
|
||||||
|
async function handleRefreshQuota() {
|
||||||
|
if (refreshingQuota.value || !props.providerId) return
|
||||||
|
refreshingQuota.value = true
|
||||||
|
try {
|
||||||
|
const result = await refreshProviderQuota(props.providerId)
|
||||||
|
if (result.success > 0) {
|
||||||
|
showSuccess(`成功刷新 ${result.success}/${result.total} 个账号的限额`)
|
||||||
|
// 重新加载数据以更新 UI
|
||||||
|
await loadEndpoints()
|
||||||
|
} else if (result.failed > 0) {
|
||||||
|
showError(`刷新失败: ${result.results.map(r => r.message).filter(Boolean).join(', ')}`, '错误')
|
||||||
|
} else {
|
||||||
|
showError('没有获取到限额信息', '警告')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '刷新限额失败', '错误')
|
||||||
|
} finally {
|
||||||
|
refreshingQuota.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleKeyChanged() {
|
async function handleKeyChanged() {
|
||||||
await loadEndpoints()
|
await loadEndpoints()
|
||||||
// 并行刷新模型列表和模型映射(因为模型权限会影响正则映射预览)
|
// 并行刷新模型列表和模型映射(因为模型权限会影响正则映射预览)
|
||||||
|
|||||||
@@ -865,3 +865,190 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
|
|||||||
await invalidate_models_list_cache()
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
return _build_key_response(new_key, api_key_plain=self.key_data.api_key)
|
return _build_key_response(new_key, api_key_plain=self.key_data.api_key)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Codex Quota Refresh API ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers/{provider_id}/refresh-quota")
|
||||||
|
async def refresh_provider_quota(
|
||||||
|
provider_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
刷新 Provider 所有 Keys 的限额信息(Codex)
|
||||||
|
|
||||||
|
向每个 Key 发送一个测试请求,从响应头中获取最新的限额信息。
|
||||||
|
仅适用于 Codex 类型的 Provider。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `success`: 成功刷新的 Key 数量
|
||||||
|
- `failed`: 失败的 Key 数量
|
||||||
|
- `results`: 每个 Key 的刷新结果
|
||||||
|
"""
|
||||||
|
adapter = AdminRefreshProviderQuotaAdapter(provider_id=provider_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
||||||
|
"""刷新 Provider 所有 Keys 的限额信息"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.api.handlers.base.request_builder import get_provider_auth
|
||||||
|
from src.services.provider.metadata_collectors import MetadataCollectorRegistry, ensure_collectors_registered
|
||||||
|
from src.services.provider.transport import build_provider_url
|
||||||
|
from src.utils.ssl_utils import get_ssl_context
|
||||||
|
|
||||||
|
# 确保 Codex 采集器已注册
|
||||||
|
ensure_collectors_registered()
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||||
|
if not provider:
|
||||||
|
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
||||||
|
|
||||||
|
# 检查是否是 Codex 类型
|
||||||
|
if provider.provider_type != "codex":
|
||||||
|
raise InvalidRequestException("仅支持 Codex 类型的 Provider 刷新限额")
|
||||||
|
|
||||||
|
# 获取所有活跃的 Keys
|
||||||
|
keys = (
|
||||||
|
db.query(ProviderAPIKey)
|
||||||
|
.filter(
|
||||||
|
ProviderAPIKey.provider_id == self.provider_id,
|
||||||
|
ProviderAPIKey.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
return {"success": 0, "failed": 0, "total": 0, "results": [], "message": "没有活跃的 Key"}
|
||||||
|
|
||||||
|
# 获取 openai:cli 端点
|
||||||
|
endpoint = None
|
||||||
|
for ep in provider.endpoints:
|
||||||
|
if ep.api_format == "openai:cli" and ep.is_active:
|
||||||
|
endpoint = ep
|
||||||
|
break
|
||||||
|
|
||||||
|
if not endpoint:
|
||||||
|
raise InvalidRequestException("找不到有效的 openai:cli 端点")
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
# 单个 Key 刷新函数
|
||||||
|
async def refresh_single_key(key: ProviderAPIKey) -> dict:
|
||||||
|
try:
|
||||||
|
# 获取认证信息
|
||||||
|
auth_info = await get_provider_auth(endpoint, key)
|
||||||
|
|
||||||
|
# 构建请求 URL
|
||||||
|
url = build_provider_url(endpoint, key=key)
|
||||||
|
|
||||||
|
# 构建请求头
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if auth_info:
|
||||||
|
headers[auth_info.auth_header] = auth_info.auth_value
|
||||||
|
else:
|
||||||
|
# 标准 API Key
|
||||||
|
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||||
|
headers["Authorization"] = f"Bearer {decrypted_key}"
|
||||||
|
|
||||||
|
# 发送最小的测试请求,使用 Codex Responses API 格式
|
||||||
|
test_body = {
|
||||||
|
"model": "gpt-5.1-codex-mini",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": "hi"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"instructions": "",
|
||||||
|
"stream": True,
|
||||||
|
"store": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=30.0, verify=get_ssl_context()
|
||||||
|
) as client:
|
||||||
|
response = await client.post(url, json=test_body, headers=headers)
|
||||||
|
|
||||||
|
# 解析响应头中的限额信息
|
||||||
|
response_headers = dict(response.headers)
|
||||||
|
metadata = MetadataCollectorRegistry.collect("codex", response_headers)
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
# 更新数据库中的元数据
|
||||||
|
key.upstream_metadata = metadata
|
||||||
|
db.add(key)
|
||||||
|
return {
|
||||||
|
"key_id": key.id,
|
||||||
|
"key_name": key.name,
|
||||||
|
"status": "success",
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 响应成功但没有限额头
|
||||||
|
return {
|
||||||
|
"key_id": key.id,
|
||||||
|
"key_name": key.name,
|
||||||
|
"status": "no_metadata",
|
||||||
|
"message": "响应中未包含限额信息",
|
||||||
|
"status_code": response.status_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"刷新 Key {key.id} 限额失败: {e}")
|
||||||
|
return {
|
||||||
|
"key_id": key.id,
|
||||||
|
"key_name": key.name,
|
||||||
|
"status": "error",
|
||||||
|
"message": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 分批执行,每批最多 5 个并发
|
||||||
|
BATCH_SIZE = 5
|
||||||
|
for i in range(0, len(keys), BATCH_SIZE):
|
||||||
|
batch = keys[i : i + BATCH_SIZE]
|
||||||
|
batch_tasks = [refresh_single_key(key) for key in batch]
|
||||||
|
batch_results = await asyncio.gather(*batch_tasks)
|
||||||
|
results.extend(batch_results)
|
||||||
|
|
||||||
|
# 统计本批次结果
|
||||||
|
for r in batch_results:
|
||||||
|
if r["status"] == "success":
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
# 提交数据库更改
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[QUOTA_REFRESH] Provider {self.provider_id}: "
|
||||||
|
f"成功 {success_count}/{len(keys)}, 失败 {failed_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": success_count,
|
||||||
|
"failed": failed_count,
|
||||||
|
"total": len(keys),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ def _ensure_collectors_registered() -> None:
|
|||||||
MetadataCollectorRegistry.register(CodexMetadataCollector())
|
MetadataCollectorRegistry.register(CodexMetadataCollector())
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_collectors_registered() -> None:
|
||||||
|
"""Ensure metadata collectors are registered (idempotent)."""
|
||||||
|
_ensure_collectors_registered()
|
||||||
|
|
||||||
|
|
||||||
def collect_and_save_upstream_metadata(
|
def collect_and_save_upstream_metadata(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
@@ -147,4 +152,5 @@ __all__ = [
|
|||||||
"MetadataCollector",
|
"MetadataCollector",
|
||||||
"MetadataCollectorRegistry",
|
"MetadataCollectorRegistry",
|
||||||
"collect_and_save_upstream_metadata",
|
"collect_and_save_upstream_metadata",
|
||||||
|
"ensure_collectors_registered",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user