更新计费

This commit is contained in:
zhanggangyong
2026-03-10 06:40:38 +00:00
parent 368198f53c
commit a540e6d61a
77 changed files with 5721 additions and 4788 deletions
+325 -190
View File
@@ -2853,6 +2853,11 @@ async def get_billing_overview(
):
"""
获取三维度计费统计(所有管理员可查看)
从 AgentBillingRecord 和 ModelBillingRecord 表查询计费数据,
返回渠道维度、租户维度和调用记录三个维度的统计。
EU 计算规则:1 EU = 10 秒运行时间(向上取整)
"""
_verify_read_permission(principal)
@@ -2866,99 +2871,227 @@ async def get_billing_overview(
if end_dt.tzinfo is not None:
end_dt = end_dt.replace(tzinfo=None)
# 渠道统计
channel_stats_result = await db.execute(
# ========== 渠道统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ==========
# 1. 从 AgentBillingRecord 统计 Agent 使用
agent_channel_stats = await db.execute(
select(
Channel.id,
Channel.name,
func.count(BillingRecord.id).label("calls"),
func.sum(BillingRecord.eu).label("total_eu"),
func.sum(BillingRecord.cost).label("total_cost"),
func.count(AgentBillingRecord.id).label("calls"),
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
func.sum(AgentBillingRecord.cost).label("total_cost"),
)
.select_from(BillingRecord)
.join(Channel, BillingRecord.channel_id == Channel.id)
.select_from(AgentBillingRecord)
.join(Channel, AgentBillingRecord.channel_id == Channel.id)
.where(
and_(
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
AgentBillingRecord.start_time >= start_dt,
AgentBillingRecord.start_time <= end_dt,
)
)
.group_by(Channel.id, Channel.name)
)
agent_channel_data = {str(row.id): {"name": row.name, "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in agent_channel_stats.all()}
channel_stats = [
{
"channelId": str(row.id),
"channelName": row.name,
"calls": row.calls,
"totalEU": float(row.total_eu or 0),
"totalCost": float(row.total_cost or 0),
}
for row in channel_stats_result.all()
]
# 2. 从 ModelBillingRecord 统计模型调用
model_channel_stats = await db.execute(
select(
Channel.id,
Channel.name,
func.count(ModelBillingRecord.id).label("calls"),
func.sum(ModelBillingRecord.eu_consumed).label("total_eu"),
func.sum(ModelBillingRecord.total_cost).label("total_cost"),
)
.select_from(ModelBillingRecord)
.join(Channel, ModelBillingRecord.channel_id == Channel.id)
.where(
and_(
ModelBillingRecord.start_time >= start_dt,
ModelBillingRecord.start_time <= end_dt,
)
)
.group_by(Channel.id, Channel.name)
)
model_channel_data = {str(row.id): {"name": row.name, "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in model_channel_stats.all()}
# 租户统计
tenant_stats_result = await db.execute(
# 3. 合并渠道统计
all_channel_ids = set(agent_channel_data.keys()) | set(model_channel_data.keys())
channel_stats = []
for channel_id in all_channel_ids:
agent_data = agent_channel_data.get(channel_id, {"name": "", "calls": 0, "eu": 0, "cost": 0})
model_data = model_channel_data.get(channel_id, {"name": "", "calls": 0, "eu": 0, "cost": 0})
channel_name = agent_data["name"] or model_data["name"]
total_calls = agent_data["calls"] + model_data["calls"]
total_eu = agent_data["eu"] + model_data["eu"]
total_cost = agent_data["cost"] + model_data["cost"]
# 应用筛选条件
if channelName and channelName.lower() not in channel_name.lower():
continue
if minCalls is not None and total_calls < minCalls:
continue
if maxCalls is not None and total_calls > maxCalls:
continue
channel_stats.append({
"channelId": channel_id,
"channelName": channel_name,
"calls": total_calls,
"totalEU": round(total_eu, 2),
"totalCost": round(total_cost, 4),
})
# ========== 租户统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ==========
# 1. 从 AgentBillingRecord 统计
agent_tenant_stats = await db.execute(
select(
User.id,
User.name,
Channel.name.label("channel_name"),
func.count(BillingRecord.id).label("calls"),
func.sum(BillingRecord.eu).label("total_eu"),
func.sum(BillingRecord.cost).label("total_cost"),
func.count(AgentBillingRecord.id).label("calls"),
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
func.sum(AgentBillingRecord.cost).label("total_cost"),
)
.select_from(BillingRecord)
.join(User, BillingRecord.tenant_id == User.id)
.join(Channel, User.channel_id == Channel.id)
.select_from(AgentBillingRecord)
.join(User, AgentBillingRecord.user_id == User.id)
.outerjoin(Channel, User.channel_id == Channel.id)
.where(
and_(
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
AgentBillingRecord.start_time >= start_dt,
AgentBillingRecord.start_time <= end_dt,
)
)
.group_by(User.id, User.name, Channel.name)
)
agent_tenant_data = {str(row.id): {"name": row.name, "channel_name": row.channel_name or "无渠道", "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in agent_tenant_stats.all()}
tenant_stats = [
{
"tenantId": str(row.id),
"tenantName": row.name,
"channelName": row.channel_name,
"calls": row.calls,
"totalEU": float(row.total_eu or 0),
"totalCost": float(row.total_cost or 0),
}
for row in tenant_stats_result.all()
]
# 调用记录
records_result = await db.execute(
select(BillingRecord, Channel.name, User.name)
.join(Channel, BillingRecord.channel_id == Channel.id)
.join(User, BillingRecord.tenant_id == User.id)
# 2. 从 ModelBillingRecord 统计
model_tenant_stats = await db.execute(
select(
User.id,
User.name,
Channel.name.label("channel_name"),
func.count(ModelBillingRecord.id).label("calls"),
func.sum(ModelBillingRecord.eu_consumed).label("total_eu"),
func.sum(ModelBillingRecord.total_cost).label("total_cost"),
)
.select_from(ModelBillingRecord)
.join(User, ModelBillingRecord.tenant_id == User.id)
.outerjoin(Channel, User.channel_id == Channel.id)
.where(
and_(
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
ModelBillingRecord.start_time >= start_dt,
ModelBillingRecord.start_time <= end_dt,
)
)
.order_by(desc(BillingRecord.timestamp))
.limit(100)
.group_by(User.id, User.name, Channel.name)
)
model_tenant_data = {str(row.id): {"name": row.name, "channel_name": row.channel_name or "无渠道", "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in model_tenant_stats.all()}
# 3. 合并租户统计
all_tenant_ids = set(agent_tenant_data.keys()) | set(model_tenant_data.keys())
tenant_stats = []
for tenant_id in all_tenant_ids:
agent_data = agent_tenant_data.get(tenant_id, {"name": "", "channel_name": "无渠道", "calls": 0, "eu": 0, "cost": 0})
model_data = model_tenant_data.get(tenant_id, {"name": "", "channel_name": "无渠道", "calls": 0, "eu": 0, "cost": 0})
tenant_name_val = agent_data["name"] or model_data["name"]
channel_name_val = agent_data["channel_name"] or model_data["channel_name"]
total_calls = agent_data["calls"] + model_data["calls"]
total_eu = agent_data["eu"] + model_data["eu"]
total_cost = agent_data["cost"] + model_data["cost"]
# 应用筛选条件
if tenantName and tenantName.lower() not in tenant_name_val.lower():
continue
if minCalls is not None and total_calls < minCalls:
continue
if maxCalls is not None and total_calls > maxCalls:
continue
# 计算平均消费
avg_cost = total_cost / total_calls if total_calls > 0 else 0
tenant_stats.append({
"tenantId": tenant_id,
"tenantName": tenant_name_val,
"channelName": channel_name_val,
"calls": total_calls,
"totalEU": round(total_eu, 2),
"totalCost": round(total_cost, 4),
"avgCost": round(avg_cost, 4),
})
# ========== 调用记录(合并 AgentBillingRecord 和 ModelBillingRecord) ==========
call_records = []
# 1. 从 AgentBillingRecord 获取记录
agent_records_result = await db.execute(
select(AgentBillingRecord, Channel.name.label("channel_name"), User.name.label("user_name"))
.outerjoin(Channel, AgentBillingRecord.channel_id == Channel.id)
.join(User, AgentBillingRecord.user_id == User.id)
.where(
and_(
AgentBillingRecord.start_time >= start_dt,
AgentBillingRecord.start_time <= end_dt,
)
)
.order_by(desc(AgentBillingRecord.start_time))
.limit(50)
)
call_records = [
{
for record, channel_name, user_name in agent_records_result.all():
call_records.append({
"id": str(record.id),
"timestamp": record.timestamp.isoformat(),
"channelName": channel_name,
"tenantName": tenant_name,
"type": "agent",
"timestamp": record.start_time.isoformat() if record.start_time else None,
"channelName": channel_name or "无渠道",
"tenantName": user_name,
"agentName": record.agent_name,
"duration": record.duration,
"eu": record.eu,
"cost": float(record.cost),
}
for record, channel_name, tenant_name in records_result.all()
]
"modelName": record.model_name,
"duration": record.duration_seconds or 0,
"eu": record.eu_consumed or 0,
"cost": float(record.cost or 0),
})
# 2. 从 ModelBillingRecord 获取记录
model_records_result = await db.execute(
select(ModelBillingRecord, Channel.name.label("channel_name"), User.name.label("user_name"))
.outerjoin(Channel, ModelBillingRecord.channel_id == Channel.id)
.join(User, ModelBillingRecord.tenant_id == User.id)
.where(
and_(
ModelBillingRecord.start_time >= start_dt,
ModelBillingRecord.start_time <= end_dt,
)
)
.order_by(desc(ModelBillingRecord.start_time))
.limit(50)
)
for record, channel_name, user_name in model_records_result.all():
# 计算 duration(从 response_time_ms 转换为秒)
duration = (record.response_time_ms or 0) / 1000
call_records.append({
"id": str(record.id),
"type": "model",
"timestamp": record.start_time.isoformat() if record.start_time else record.created_at.isoformat(),
"channelName": channel_name or "无渠道",
"tenantName": user_name,
"agentName": None,
"modelName": record.model_name,
"duration": round(duration, 2),
"eu": float(record.eu_consumed or 0),
"cost": float(record.total_cost or 0),
"inputTokens": record.input_tokens,
"outputTokens": record.output_tokens,
"totalTokens": record.total_tokens,
})
# 3. 按时间排序并限制数量
call_records.sort(key=lambda x: x["timestamp"] or "", reverse=True)
call_records = call_records[:100]
# 如果是导出请求
if export:
@@ -3497,45 +3630,6 @@ async def get_admin_roles(
# ============= 平台 Agent 资源申请审批 =============
# 模板显示信息(用于在 Agent Manager 返回数据不包含显示名称时提供默认值)
TEMPLATE_DISPLAY_INFO: Dict[str, Dict[str, str]] = {
"echo_agent": {
"displayName": "Echo 测试服务",
"description": "简单的 Echo 服务,用于测试和调试",
"category": "testing",
},
"chat_agent": {
"displayName": "聊天对话服务",
"description": "通用聊天对话 Agent,支持多轮对话",
"category": "assistant",
},
"code_agent": {
"displayName": "代码执行服务",
"description": "代码生成和执行 Agent,支持多种编程语言",
"category": "development",
},
"search_agent": {
"displayName": "通用搜索服务",
"description": "通用搜索 Agent,支持多种搜索引擎",
"category": "search",
},
"jina_search_agent": {
"displayName": "Jina 搜索服务",
"description": "基于 Jina AI 的语义搜索 Agent",
"category": "search",
},
"mysql_agent": {
"displayName": "MySQL 数据库客户端",
"description": "MySQL 数据库查询和管理 Agent",
"category": "database",
},
"postgresql_agent": {
"displayName": "PostgreSQL 数据库客户端",
"description": "PostgreSQL 数据库查询和管理 Agent",
"category": "database",
},
}
# 模板资源配置建议
TEMPLATE_RESOURCE_CONFIG: Dict[str, Dict[str, str]] = {
"echo_agent": {
@@ -3607,18 +3701,22 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession]
result = []
for template in templates:
template_name = template.template
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
default_resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, {})
# 优先使用数据库中的管理员配置,否则使用默认值
# 优先使用 Agent Manager 返回的 displayName 和 description
agent_manager_display_name = template.display_name
agent_manager_description = template.description
agent_manager_category = template.category
# 优先使用数据库中的管理员配置,否则使用 Agent Manager 返回值
db_config = db_configs.get(template_name)
if db_config:
# 使用数据库配置覆盖默认值
result.append({
"name": template_name,
"displayName": db_config.display_name or display_info.get("displayName", template_name),
"description": db_config.description or display_info.get("description", f"{template_name} Agent"),
"category": display_info.get("category", "general"),
"displayName": db_config.display_name or agent_manager_display_name or template_name,
"description": db_config.description or agent_manager_description or f"{template_name} Agent",
"category": agent_manager_category or "general",
"version": "1.0.0",
"port": template.port,
"envInfo": template.env_info,
@@ -3631,12 +3729,12 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession]
"status": "available" if (db_config.is_enabled is None or db_config.is_enabled) else "disabled",
})
else:
# 使用默认配置
# 使用 Agent Manager 返回值和默认资源配置
result.append({
"name": template_name,
"displayName": display_info.get("displayName", template_name),
"description": display_info.get("description", f"{template_name} Agent"),
"category": display_info.get("category", "general"),
"displayName": agent_manager_display_name or template_name,
"description": agent_manager_description or f"{template_name} Agent",
"category": agent_manager_category or "general",
"version": "1.0.0",
"port": template.port,
"envInfo": template.env_info,
@@ -3653,45 +3751,9 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession]
return result
except Exception as e:
logger.warning("admin: 从 Agent Manager 获取平台模板失败,使用默认模板", error=str(e))
# 返回基于 TEMPLATE_DISPLAY_INFO 的默认模板
result = []
for name, info in TEMPLATE_DISPLAY_INFO.items():
default_resource_config = TEMPLATE_RESOURCE_CONFIG.get(name, {})
# 优先使用数据库中的管理员配置
db_config = db_configs.get(name)
if db_config:
result.append({
"name": name,
"displayName": db_config.display_name or info.get("displayName", name),
"description": db_config.description or info.get("description", f"{name} Agent"),
"category": info.get("category", "general"),
"version": "1.0.0",
"cpuRequest": db_config.cpu_request or default_resource_config.get("cpuRequest", "100m"),
"cpuLimit": db_config.cpu_limit or default_resource_config.get("cpuLimit", "500m"),
"memoryRequest": db_config.memory_request or default_resource_config.get("memoryRequest", "128Mi"),
"memoryLimit": db_config.memory_limit or default_resource_config.get("memoryLimit", "512Mi"),
"maxPods": db_config.max_pods if db_config.max_pods is not None else 10,
"isEnabled": db_config.is_enabled if db_config.is_enabled is not None else True,
"status": "available" if (db_config.is_enabled is None or db_config.is_enabled) else "disabled",
})
else:
result.append({
"name": name,
"displayName": info.get("displayName", name),
"description": info.get("description", f"{name} Agent"),
"category": info.get("category", "general"),
"version": "1.0.0",
"cpuRequest": default_resource_config.get("cpuRequest", "100m"),
"cpuLimit": default_resource_config.get("cpuLimit", "500m"),
"memoryRequest": default_resource_config.get("memoryRequest", "128Mi"),
"memoryLimit": default_resource_config.get("memoryLimit", "512Mi"),
"maxPods": 10,
"isEnabled": True,
"status": "available",
})
return result
logger.error("admin: 从 Agent Manager 获取平台模板失败", error=str(e))
# Agent Manager 不可用时,返回空列表
return []
async def _validate_template_exists(template_name: str) -> bool:
@@ -3702,8 +3764,8 @@ async def _validate_template_exists(template_name: str) -> bool:
templates = await client.list_platform_templates()
return any(t.template == template_name for t in templates)
except Exception as e:
logger.warning("admin: 验证模板失败,使用本地验证", error=str(e))
return template_name in TEMPLATE_DISPLAY_INFO
logger.error("admin: 验证模板失败", error=str(e))
return False
@router.get("/platform-agents/templates", response_model=SuccessResponse)
@@ -3731,51 +3793,128 @@ async def list_platform_agent_templates(
async def list_platform_agent_applications(
status_filter: Optional[str] = Query(None, alias="status", pattern="^(pending|approved|rejected)$"),
channel_id: Optional[str] = Query(None),
limit: int = Query(10, ge=1, le=50, description="返回数量,默认10条,最多50条"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取平台 Agent 申请列表(管理员视图)
默认优先返回未审批(pending)的申请,如果未审批数量不足则用已审批的填充。
如果指定了 status 参数,则只返回该状态的申请。
权限:view:applications (所有管理员)
"""
_verify_read_permission(principal)
# 构建查询
query = select(ResourceApplication, Channel).join(
Channel, ResourceApplication.channel_id == Channel.id
).where(
ResourceApplication.resource_type == "platform_agent"
)
if status_filter:
query = query.where(ResourceApplication.status == status_filter)
if channel_id:
query = query.where(ResourceApplication.channel_id == channel_id)
query = query.order_by(desc(ResourceApplication.created_at))
result = await db.execute(query)
data = []
for app, channel in result.all():
display_info = TEMPLATE_DISPLAY_INFO.get(app.template_name, {})
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name,
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": display_info.get("displayName", app.template_name),
"requestedPodQuota": app.requested_pod_quota,
"approvedPodQuota": app.approved_pod_quota,
"reason": app.reason,
"status": app.status,
"reviewReason": app.review_reason,
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
"createdAt": app.created_at.isoformat(),
})
# 如果指定了状态筛选,直接按该状态查询
if status_filter:
query = select(ResourceApplication, Channel).join(
Channel, ResourceApplication.channel_id == Channel.id
).where(
and_(
ResourceApplication.resource_type == "platform_agent",
ResourceApplication.status == status_filter
)
)
if channel_id:
query = query.where(ResourceApplication.channel_id == channel_id)
query = query.order_by(desc(ResourceApplication.created_at)).limit(limit)
result = await db.execute(query)
for app, channel in result.all():
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name,
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": app.template_name,
"requestedPodQuota": app.requested_pod_quota,
"approvedPodQuota": app.approved_pod_quota,
"reason": app.reason,
"status": app.status,
"reviewReason": app.review_reason,
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
"createdAt": app.created_at.isoformat(),
})
else:
# 未指定状态筛选时,优先返回 pending 状态的申请
# 1. 先查询 pending 状态的申请(最多 limit 条)
pending_query = select(ResourceApplication, Channel).join(
Channel, ResourceApplication.channel_id == Channel.id
).where(
and_(
ResourceApplication.resource_type == "platform_agent",
ResourceApplication.status == "pending"
)
)
if channel_id:
pending_query = pending_query.where(ResourceApplication.channel_id == channel_id)
pending_query = pending_query.order_by(desc(ResourceApplication.created_at)).limit(limit)
pending_result = await db.execute(pending_query)
pending_apps = pending_result.all()
for app, channel in pending_apps:
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name,
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": app.template_name,
"requestedPodQuota": app.requested_pod_quota,
"approvedPodQuota": app.approved_pod_quota,
"reason": app.reason,
"status": app.status,
"reviewReason": app.review_reason,
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
"createdAt": app.created_at.isoformat(),
})
# 2. 如果 pending 数量不足 limit,用已审批的(approved/rejected)填充
remaining = limit - len(data)
if remaining > 0:
reviewed_query = select(ResourceApplication, Channel).join(
Channel, ResourceApplication.channel_id == Channel.id
).where(
and_(
ResourceApplication.resource_type == "platform_agent",
ResourceApplication.status.in_(["approved", "rejected"])
)
)
if channel_id:
reviewed_query = reviewed_query.where(ResourceApplication.channel_id == channel_id)
reviewed_query = reviewed_query.order_by(desc(ResourceApplication.created_at)).limit(remaining)
reviewed_result = await db.execute(reviewed_query)
for app, channel in reviewed_result.all():
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name,
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": app.template_name,
"requestedPodQuota": app.requested_pod_quota,
"approvedPodQuota": app.approved_pod_quota,
"reason": app.reason,
"status": app.status,
"reviewReason": app.review_reason,
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
"createdAt": app.created_at.isoformat(),
})
return SuccessResponse(data={"applications": data})
@@ -3918,8 +4057,6 @@ async def list_platform_agent_allocations(
data = []
for quota, channel in result.all():
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
# 从模板配置中获取管理员设置的CPU和内存限制
template_config = template_configs.get(quota.template_name)
cpu_limit = template_config.cpu_limit if template_config and template_config.cpu_limit else "100m"
@@ -3930,7 +4067,7 @@ async def list_platform_agent_allocations(
"channelId": str(quota.target_id),
"channelName": channel.name if channel else "未知",
"templateName": quota.template_name,
"templateDisplayName": display_info.get("displayName", quota.template_name),
"templateDisplayName": quota.template_name,
"podQuota": quota.pod_quota,
"podUsed": quota.pod_used,
"podRemaining": quota.pod_quota - quota.pod_used,
@@ -4024,14 +4161,12 @@ async def allocate_platform_agent_to_channel(
await db.commit()
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
return SuccessResponse(
data={
"channelId": str(channel_uuid),
"channelName": channel.name,
"templateName": template_name,
"templateDisplayName": display_info.get("displayName", template_name),
"templateDisplayName": template_name,
"podQuota": pod_quota,
},
message=message