"""任务启动点数预检 + 运行中熔断(用户反馈:没点数了还在跑)。 预检(readiness 阶段):按成本预估与当前余额比较,余额必须覆盖预估 + 20% 缓冲。 熔断(runner 每段结束后):点数/余额类错误说明继续跑只会白跑,立刻停止整个任务。 """ from __future__ import annotations from sqlmodel import Session from .billing import point_state # 启动缓冲:预估之外还要留 20%,防止输出超长导致中途断粮 BALANCE_BUFFER_RATIO = 0.2 def points_preflight(session: Session, campaign, estimated_points: int) -> dict: """启动前点数校验。返回 {ok, blocker, info}。 ok=False 时 blocker 是可直接展示的中文原因。 """ owner_id = int(campaign.owner_user_id or 0) org_id = int(campaign.organization_id or 1) if not owner_id: return {"ok": True, "blocker": "", "info": None} state = point_state(session, owner_id, org_id) balance = int(state.get("points") or 0) need = int(estimated_points or 0) required = int(need * (1 + BALANCE_BUFFER_RATIO)) info = {"balance": balance, "estimated_points": need, "required_points": required, "buffer_ratio": BALANCE_BUFFER_RATIO} if balance < required: gap = required - balance return { "ok": False, "blocker": (f"点数不足:本次预估需要约 {need:,} 点数(含 20% 缓冲共 {required:,})," f"当前余额 {balance:,},还差 {gap:,}。请联系管理员在「用量与账单」增加点数后重试"), "info": info, } return {"ok": True, "blocker": "", "info": info} # 段级失败里属于「继续跑没有意义」的错误类型:钱的问题不会自愈。 CIRCUIT_BREAK_FAILURE_TYPES = {"billing", "balance", "auth"} def should_break_run(failure: dict | None) -> tuple[bool, str]: """根据 execute_segment 返回的 failure 判断是否熔断整个任务。 返回 (是否熔断, 原因)。 """ if not failure: return False, "" ftype = str(failure.get("type") or "") detail = str(failure.get("detail") or "") if ftype in CIRCUIT_BREAK_FAILURE_TYPES: return True, detail[:160] or "模型服务拒绝(余额/鉴权问题)" low = detail.lower() if ("点数余额不足" in detail or "余额不足" in detail or "insufficient" in low and ("balance" in low or "quota" in low) or "invalid api key" in low): return True, detail[:160] return False, "" __all__ = ["points_preflight", "should_break_run", "BALANCE_BUFFER_RATIO", "CIRCUIT_BREAK_FAILURE_TYPES"]