check_browser_model.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import argparse
  2. import asyncio
  3. import json
  4. import os
  5. import secrets
  6. import time
  7. from pathlib import Path
  8. from urllib.parse import urlparse
  9. from uuid import UUID, uuid4
  10. from anyio import Path as AsyncPath
  11. from redis.asyncio import Redis
  12. from sqlalchemy import select, update
  13. from sqlalchemy.ext.asyncio import async_sessionmaker
  14. from zhifu.core.config import Settings
  15. from zhifu.core.database import create_engine
  16. from zhifu.core.errors import BusinessError
  17. from zhifu.modules.agent_runtime.budget_callback import CallAllowance
  18. from zhifu.modules.agent_runtime.capacity import release
  19. from zhifu.modules.agent_runtime.config import AgentSettings
  20. from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt, TokenEntry
  21. from zhifu.modules.agent_runtime.runtime import build_graph
  22. from zhifu.modules.agent_runtime.worker import claim
  23. from zhifu.modules.billing.ledger import ADMISSION_ERRORS
  24. from zhifu.modules.billing.models import MoneyEntry
  25. from zhifu.modules.conversations.models import Conversation
  26. from zhifu.modules.conversations.service import finish_run
  27. from zhifu.modules.identity.access_models import AccessSession, Principal, StoreGrant
  28. from zhifu.modules.identity.auth import password_hasher
  29. from zhifu.modules.identity.models import Store, Tenant
  30. from zhifu.modules.knowledge.vector import retrieval_client
  31. BUDGET_PATH = ".work/frontend-verification/customer-model-allowance.json"
  32. async def read_allowance():
  33. path = AsyncPath(BUDGET_PATH)
  34. ledger = json.loads(await path.read_text(encoding="utf-8")) if await path.exists() else {
  35. "maximum_provider_attempts": 3, "runs": [],
  36. }
  37. if ledger["maximum_provider_attempts"] != 3:
  38. raise ValueError("专项累计供应商次数上限必须为 3")
  39. remaining = 3 - sum(item["charged_attempts"] for item in ledger["runs"])
  40. if remaining <= 0:
  41. raise RuntimeError("本专项累计供应商尝试已达到上限,禁止继续生成")
  42. return ledger, remaining
  43. def write_report(report, path):
  44. destination = Path(path).resolve()
  45. workspace = Path(__file__).resolve().parents[1] / ".work"
  46. if not destination.is_relative_to(workspace):
  47. raise ValueError("报告必须保存在项目 .work 目录")
  48. destination.parent.mkdir(parents=True, exist_ok=True)
  49. destination.write_text(
  50. json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
  51. )
  52. async def prepare_account(sessions, configuration):
  53. if configuration.environment == "production":
  54. raise ValueError("浏览器采样账户只允许在 development 或 test 环境创建")
  55. source = json.loads(await AsyncPath(".work/frontend-accounts.json").read_text(encoding="utf-8"))
  56. actor = uuid4()
  57. tenant_id, store_id = UUID(source["tenant_id"]), UUID(source["store_id"])
  58. username, password = "browser-model-" + actor.hex, secrets.token_urlsafe(24)
  59. encoded = await asyncio.to_thread(password_hasher.hash, password)
  60. async with sessions.begin() as session:
  61. tenant = await session.get(Tenant, tenant_id.bytes)
  62. store = await session.get(Store, store_id.bytes)
  63. if tenant.status != "ACTIVE" or store.status != "ACTIVE":
  64. raise RuntimeError("开发商家或店铺未启用")
  65. session.add(Principal(
  66. id=actor.bytes, tenant_id=tenant_id.bytes, username=username,
  67. password_hash=encoded, kind="CUSTOMER",
  68. ))
  69. await session.flush()
  70. session.add(StoreGrant(
  71. tenant_id=tenant_id.bytes, principal_id=actor.bytes,
  72. store_id=store_id.bytes, permission="orders:read",
  73. ))
  74. await asyncio.to_thread(write_report, {
  75. "tenant_id": str(tenant_id), "store_id": str(store_id),
  76. "actor_id": str(actor), "username": username, "password": password,
  77. }, ".work/browser-model-account.json")
  78. async def cleanup_account(sessions):
  79. credentials = json.loads(
  80. await AsyncPath(".work/browser-model-account.json").read_text(encoding="utf-8"),
  81. )
  82. actor_id = UUID(credentials["actor_id"]).bytes
  83. async with sessions.begin() as session:
  84. principal = await session.get(Principal, actor_id, with_for_update=True)
  85. if principal.username != credentials["username"] or not principal.username.startswith(
  86. "browser-model-",
  87. ):
  88. raise RuntimeError("清理目标与专项账户不同")
  89. # 关闭专项账户与全部会话,保留已发生的生成用量和业务事件供核验。
  90. principal.status = "SUSPENDED"
  91. principal.version += 1
  92. await session.execute(update(AccessSession).where(
  93. AccessSession.principal_id == actor_id,
  94. ).values(revoked=True))
  95. await AsyncPath(".work/browser-model-account.json").unlink()
  96. async def check_queue(sessions, state, expected=None):
  97. # 专项期间只允许当前浏览器任务,持续生成工作进程会使次数限制失去完整范围。
  98. for role in ("agent", "memory"):
  99. async for _ in state.scan_iter(match=f"monitor:worker:{role}:*"):
  100. raise RuntimeError("生成工作进程仍有有效心跳,请停止后等待心跳过期")
  101. async with sessions() as session:
  102. pending = set((await session.scalars(select(AgentRun.id).where(
  103. AgentRun.status.in_(["QUEUED", "RUNNING"]),
  104. ))).all())
  105. if pending != ({expected} if expected else set()):
  106. raise RuntimeError("队列存在其他运行或目标任务状态不同,专项未调用模型")
  107. async def snapshot(sessions, identifier):
  108. async with sessions() as session:
  109. run = await session.get(AgentRun, identifier)
  110. attempts = (await session.scalars(select(ModelAttempt).where(
  111. ModelAttempt.run_id == identifier,
  112. ).order_by(ModelAttempt.created_at, ModelAttempt.id))).all()
  113. entries = (await session.scalars(select(TokenEntry).where(
  114. TokenEntry.reference_id.in_([item.id for item in attempts]),
  115. TokenEntry.kind == "agent",
  116. ))).all() if attempts else []
  117. money = (await session.scalars(select(MoneyEntry).where(
  118. MoneyEntry.id.in_([item.id for item in entries]),
  119. ))).all() if entries else []
  120. return {
  121. "run_status": run.status if run else None,
  122. "run_error_code": run.error_code if run else None,
  123. "model_attempts": [{
  124. "id": str(UUID(bytes=item.id)), "status": item.status, "usage": item.usage,
  125. } for item in attempts],
  126. "token_entries": [{
  127. "id": str(UUID(bytes=item.id)),
  128. "model_attempt_id": str(UUID(bytes=item.reference_id)),
  129. "model": item.model_name, "status": item.status,
  130. "reserved": item.reserved, "usage": item.usage,
  131. "cache_read_tokens": (
  132. item.usage.get("input_token_details", {}).get("cache_read")
  133. if item.usage else None
  134. ),
  135. } for item in entries],
  136. "money_entries": [{
  137. "id": str(UUID(bytes=item.id)), "status": item.status,
  138. "charged_micro_cny": item.charged, "reserved_micro_cny": item.reserved,
  139. "basis": item.basis, "price_id": item.price["price_id"], "price": item.price,
  140. } for item in money],
  141. }
  142. async def main(args):
  143. configuration = Settings()
  144. engine = create_engine(configuration)
  145. sessions = async_sessionmaker(engine, expire_on_commit=False)
  146. state = Redis.from_url(configuration.state_url.get_secret_value(), socket_timeout=3)
  147. allowance = CallAllowance(0)
  148. capacity_id = uuid4().hex
  149. started = time.monotonic()
  150. identifier = UUID(args.run_id).bytes if args.run_id else None
  151. token = None
  152. budget_lock = None
  153. ledger = None
  154. ledger_run = None
  155. report = {"maximum_provider_attempts": 3, "maximum_duration_seconds": args.timeout_seconds}
  156. try:
  157. async with asyncio.timeout(args.timeout_seconds):
  158. if args.cleanup_account:
  159. await cleanup_account(sessions)
  160. print(json.dumps({"cleanup": "passed", "model_requests": 0}))
  161. return
  162. await check_queue(sessions, state, identifier)
  163. if args.preflight:
  164. if not os.environ.get("DEEPSEEK_API_KEY"):
  165. raise RuntimeError("进程环境缺少 DEEPSEEK_API_KEY")
  166. await read_allowance()
  167. if args.prepare_account:
  168. await prepare_account(sessions, configuration)
  169. print(json.dumps({"preflight": "passed", "model_requests": 0}))
  170. return
  171. if identifier is None or not args.conversation_id or not args.report:
  172. raise ValueError("执行需要 run-id、conversation-id 和 report")
  173. settings = AgentSettings(max_output_tokens=1024)
  174. async with sessions() as session:
  175. run = await session.get(AgentRun, identifier)
  176. if run.conversation_id != UUID(args.conversation_id).bytes or run.model_calls:
  177. raise RuntimeError("运行所属会话不同或已有模型调用,专项终止")
  178. endpoint = urlparse(run.model_url)
  179. if endpoint.scheme != "https" or endpoint.hostname != "api.deepseek.com":
  180. raise RuntimeError("本专项只允许已配置的 DeepSeek HTTPS 模型地址")
  181. effective = run.release_snapshot or {
  182. "max_output_tokens": settings.max_output_tokens,
  183. "model_timeout": settings.model_timeout,
  184. "model_extra_body": settings.model_extra_body,
  185. }
  186. output_limit = effective["max_output_tokens"]
  187. if not 1 <= output_limit <= 1024 or not 0 < effective["model_timeout"] <= 30:
  188. raise RuntimeError("发布快照的输出或超时参数超过本次采样上限")
  189. extra = effective["model_extra_body"]
  190. for name in ("max_tokens", "max_completion_tokens", "max_output_tokens"):
  191. if name in extra and extra[name] > output_limit:
  192. raise RuntimeError("模型附加参数超过发布快照的输出上限")
  193. if extra.get("n", 1) != 1:
  194. raise RuntimeError("本专项仅允许单候选响应")
  195. os.environ[run.model_credential_env] = os.environ["DEEPSEEK_API_KEY"]
  196. report.update({
  197. "run_id": args.run_id, "conversation_id": args.conversation_id,
  198. "model": run.model_name, "provider": run.model_url,
  199. "effective_max_output_tokens": output_limit,
  200. "effective_model_timeout_seconds": effective["model_timeout"],
  201. "automatic_provider_retries": 0,
  202. })
  203. # 跨次执行共享三次总额度;进程被终止时保留已预留次数,禁止自动增加额度。
  204. budget_lock = state.lock(
  205. "verification:browser-model:allowance", timeout=100, blocking_timeout=2,
  206. )
  207. if not await budget_lock.acquire():
  208. raise RuntimeError("另一次浏览器模型专项正在执行")
  209. ledger, remaining = await read_allowance()
  210. ledger_run = {
  211. "run_id": args.run_id, "status": "RESERVED", "charged_attempts": remaining,
  212. }
  213. ledger["runs"].append(ledger_run)
  214. await asyncio.to_thread(write_report, ledger, BUDGET_PATH)
  215. allowance = CallAllowance(remaining)
  216. claimed = await claim(
  217. sessions, settings.tenant_concurrency, state=state,
  218. settings=settings, capacity_id=capacity_id,
  219. )
  220. if claimed is None or claimed[0] != identifier:
  221. raise RuntimeError("领取结果与浏览器运行不同,专项未调用模型")
  222. _, token = claimed
  223. async with retrieval_client() as knowledge:
  224. graph = build_graph(sessions, settings, knowledge.search, allowance=allowance)
  225. await graph.ainvoke(
  226. {"run_id": identifier, "token": token, "next_node": "model"},
  227. {"recursion_limit": 24},
  228. )
  229. except BaseException as failure:
  230. report["failure_type"] = type(failure).__name__
  231. failure_code = (
  232. failure.code if isinstance(failure, BusinessError) else type(failure).__name__
  233. )
  234. report["failure_code"] = failure_code
  235. not_sent = isinstance(failure, BusinessError) and failure.code in (
  236. ADMISSION_ERRORS | {"MODEL_SAMPLE_LIMIT"}
  237. )
  238. if identifier and token is not None:
  239. # 终止仅更新本人领取的运行,真实已发生用量由模型回调保留。
  240. async with sessions.begin() as session:
  241. run = await session.get(AgentRun, identifier)
  242. await session.get(Conversation, run.conversation_id, with_for_update=True)
  243. await session.refresh(run, with_for_update=True)
  244. if run.status == "RUNNING" and run.lease_token == token:
  245. run.status = "FAILED"
  246. run.error_code = failure_code[:64]
  247. run.lease_until = None
  248. await finish_run(session, run, "run.failed", {"code": run.error_code})
  249. await session.execute(update(ModelAttempt).where(
  250. ModelAttempt.run_id == identifier, ModelAttempt.status == "STARTED",
  251. ).values(status="NOT_SENT" if not_sent else "UNKNOWN"))
  252. raise
  253. finally:
  254. try:
  255. await release(state, capacity_id)
  256. if identifier and args.report:
  257. report.update(await snapshot(sessions, identifier))
  258. # 回调先检查次数和预算;只有预算事务通过后才进入供应商请求。
  259. report["callback_admission_attempts"] = allowance.started
  260. report["provider_attempts_started"] = len(report["token_entries"])
  261. report["provider_attempts_received"] = sum(
  262. item["status"] == "RECEIVED" for item in report["model_attempts"]
  263. )
  264. report["provider_attempts_result_unknown"] = sum(
  265. item["status"] in {"UNKNOWN", "PENDING"} for item in report["token_entries"]
  266. )
  267. report["admission_rejections"] = sum(
  268. item["status"] == "NOT_SENT" for item in report["model_attempts"]
  269. )
  270. if ledger_run is not None:
  271. ledger_run.update({
  272. "status": "RECORDED",
  273. "charged_attempts": report["provider_attempts_started"],
  274. "callback_admission_attempts": allowance.started,
  275. "failure_code": report.get("failure_code"),
  276. })
  277. await asyncio.to_thread(write_report, ledger, BUDGET_PATH)
  278. report["cumulative_provider_attempts"] = sum(
  279. item["charged_attempts"] for item in ledger["runs"]
  280. )
  281. report["elapsed_seconds"] = round(time.monotonic() - started, 3)
  282. await asyncio.to_thread(write_report, report, args.report)
  283. evidence = str(Path(args.report).with_name(
  284. f"customer-model-worker-{args.run_id}.json",
  285. ))
  286. await asyncio.to_thread(write_report, report, evidence)
  287. finally:
  288. if budget_lock is not None and await budget_lock.owned():
  289. await budget_lock.release()
  290. await state.aclose()
  291. await engine.dispose()
  292. if __name__ == "__main__":
  293. parser = argparse.ArgumentParser(description="浏览器 AI 有界验证的单次工作进程")
  294. parser.add_argument("--preflight", action="store_true")
  295. parser.add_argument("--prepare-account", action="store_true")
  296. parser.add_argument("--cleanup-account", action="store_true")
  297. parser.add_argument("--run-id")
  298. parser.add_argument("--conversation-id")
  299. parser.add_argument("--report")
  300. parser.add_argument("--timeout-seconds", type=int, default=80, choices=range(1, 86))
  301. asyncio.run(main(parser.parse_args()))