| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- import argparse
- import asyncio
- import json
- import os
- import secrets
- import time
- from pathlib import Path
- from urllib.parse import urlparse
- from uuid import UUID, uuid4
- from anyio import Path as AsyncPath
- from redis.asyncio import Redis
- from sqlalchemy import select, update
- from sqlalchemy.ext.asyncio import async_sessionmaker
- from zhifu.core.config import Settings
- from zhifu.core.database import create_engine
- from zhifu.core.errors import BusinessError
- from zhifu.modules.agent_runtime.budget_callback import CallAllowance
- from zhifu.modules.agent_runtime.capacity import release
- from zhifu.modules.agent_runtime.config import AgentSettings
- from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt, TokenEntry
- from zhifu.modules.agent_runtime.runtime import build_graph
- from zhifu.modules.agent_runtime.worker import claim
- from zhifu.modules.billing.ledger import ADMISSION_ERRORS
- from zhifu.modules.billing.models import MoneyEntry
- from zhifu.modules.conversations.models import Conversation
- from zhifu.modules.conversations.service import finish_run
- from zhifu.modules.identity.access_models import AccessSession, Principal, StoreGrant
- from zhifu.modules.identity.auth import password_hasher
- from zhifu.modules.identity.models import Store, Tenant
- from zhifu.modules.knowledge.vector import retrieval_client
- BUDGET_PATH = ".work/frontend-verification/customer-model-allowance.json"
- async def read_allowance():
- path = AsyncPath(BUDGET_PATH)
- ledger = json.loads(await path.read_text(encoding="utf-8")) if await path.exists() else {
- "maximum_provider_attempts": 3, "runs": [],
- }
- if ledger["maximum_provider_attempts"] != 3:
- raise ValueError("专项累计供应商次数上限必须为 3")
- remaining = 3 - sum(item["charged_attempts"] for item in ledger["runs"])
- if remaining <= 0:
- raise RuntimeError("本专项累计供应商尝试已达到上限,禁止继续生成")
- return ledger, remaining
- def write_report(report, path):
- destination = Path(path).resolve()
- workspace = Path(__file__).resolve().parents[1] / ".work"
- if not destination.is_relative_to(workspace):
- raise ValueError("报告必须保存在项目 .work 目录")
- destination.parent.mkdir(parents=True, exist_ok=True)
- destination.write_text(
- json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
- )
- async def prepare_account(sessions, configuration):
- if configuration.environment == "production":
- raise ValueError("浏览器采样账户只允许在 development 或 test 环境创建")
- source = json.loads(await AsyncPath(".work/frontend-accounts.json").read_text(encoding="utf-8"))
- actor = uuid4()
- tenant_id, store_id = UUID(source["tenant_id"]), UUID(source["store_id"])
- username, password = "browser-model-" + actor.hex, secrets.token_urlsafe(24)
- encoded = await asyncio.to_thread(password_hasher.hash, password)
- async with sessions.begin() as session:
- tenant = await session.get(Tenant, tenant_id.bytes)
- store = await session.get(Store, store_id.bytes)
- if tenant.status != "ACTIVE" or store.status != "ACTIVE":
- raise RuntimeError("开发商家或店铺未启用")
- session.add(Principal(
- id=actor.bytes, tenant_id=tenant_id.bytes, username=username,
- password_hash=encoded, kind="CUSTOMER",
- ))
- await session.flush()
- session.add(StoreGrant(
- tenant_id=tenant_id.bytes, principal_id=actor.bytes,
- store_id=store_id.bytes, permission="orders:read",
- ))
- await asyncio.to_thread(write_report, {
- "tenant_id": str(tenant_id), "store_id": str(store_id),
- "actor_id": str(actor), "username": username, "password": password,
- }, ".work/browser-model-account.json")
- async def cleanup_account(sessions):
- credentials = json.loads(
- await AsyncPath(".work/browser-model-account.json").read_text(encoding="utf-8"),
- )
- actor_id = UUID(credentials["actor_id"]).bytes
- async with sessions.begin() as session:
- principal = await session.get(Principal, actor_id, with_for_update=True)
- if principal.username != credentials["username"] or not principal.username.startswith(
- "browser-model-",
- ):
- raise RuntimeError("清理目标与专项账户不同")
- # 关闭专项账户与全部会话,保留已发生的生成用量和业务事件供核验。
- principal.status = "SUSPENDED"
- principal.version += 1
- await session.execute(update(AccessSession).where(
- AccessSession.principal_id == actor_id,
- ).values(revoked=True))
- await AsyncPath(".work/browser-model-account.json").unlink()
- async def check_queue(sessions, state, expected=None):
- # 专项期间只允许当前浏览器任务,持续生成工作进程会使次数限制失去完整范围。
- for role in ("agent", "memory"):
- async for _ in state.scan_iter(match=f"monitor:worker:{role}:*"):
- raise RuntimeError("生成工作进程仍有有效心跳,请停止后等待心跳过期")
- async with sessions() as session:
- pending = set((await session.scalars(select(AgentRun.id).where(
- AgentRun.status.in_(["QUEUED", "RUNNING"]),
- ))).all())
- if pending != ({expected} if expected else set()):
- raise RuntimeError("队列存在其他运行或目标任务状态不同,专项未调用模型")
- async def snapshot(sessions, identifier):
- async with sessions() as session:
- run = await session.get(AgentRun, identifier)
- attempts = (await session.scalars(select(ModelAttempt).where(
- ModelAttempt.run_id == identifier,
- ).order_by(ModelAttempt.created_at, ModelAttempt.id))).all()
- entries = (await session.scalars(select(TokenEntry).where(
- TokenEntry.reference_id.in_([item.id for item in attempts]),
- TokenEntry.kind == "agent",
- ))).all() if attempts else []
- money = (await session.scalars(select(MoneyEntry).where(
- MoneyEntry.id.in_([item.id for item in entries]),
- ))).all() if entries else []
- return {
- "run_status": run.status if run else None,
- "run_error_code": run.error_code if run else None,
- "model_attempts": [{
- "id": str(UUID(bytes=item.id)), "status": item.status, "usage": item.usage,
- } for item in attempts],
- "token_entries": [{
- "id": str(UUID(bytes=item.id)),
- "model_attempt_id": str(UUID(bytes=item.reference_id)),
- "model": item.model_name, "status": item.status,
- "reserved": item.reserved, "usage": item.usage,
- "cache_read_tokens": (
- item.usage.get("input_token_details", {}).get("cache_read")
- if item.usage else None
- ),
- } for item in entries],
- "money_entries": [{
- "id": str(UUID(bytes=item.id)), "status": item.status,
- "charged_micro_cny": item.charged, "reserved_micro_cny": item.reserved,
- "basis": item.basis, "price_id": item.price["price_id"], "price": item.price,
- } for item in money],
- }
- async def main(args):
- configuration = Settings()
- engine = create_engine(configuration)
- sessions = async_sessionmaker(engine, expire_on_commit=False)
- state = Redis.from_url(configuration.state_url.get_secret_value(), socket_timeout=3)
- allowance = CallAllowance(0)
- capacity_id = uuid4().hex
- started = time.monotonic()
- identifier = UUID(args.run_id).bytes if args.run_id else None
- token = None
- budget_lock = None
- ledger = None
- ledger_run = None
- report = {"maximum_provider_attempts": 3, "maximum_duration_seconds": args.timeout_seconds}
- try:
- async with asyncio.timeout(args.timeout_seconds):
- if args.cleanup_account:
- await cleanup_account(sessions)
- print(json.dumps({"cleanup": "passed", "model_requests": 0}))
- return
- await check_queue(sessions, state, identifier)
- if args.preflight:
- if not os.environ.get("DEEPSEEK_API_KEY"):
- raise RuntimeError("进程环境缺少 DEEPSEEK_API_KEY")
- await read_allowance()
- if args.prepare_account:
- await prepare_account(sessions, configuration)
- print(json.dumps({"preflight": "passed", "model_requests": 0}))
- return
- if identifier is None or not args.conversation_id or not args.report:
- raise ValueError("执行需要 run-id、conversation-id 和 report")
- settings = AgentSettings(max_output_tokens=1024)
- async with sessions() as session:
- run = await session.get(AgentRun, identifier)
- if run.conversation_id != UUID(args.conversation_id).bytes or run.model_calls:
- raise RuntimeError("运行所属会话不同或已有模型调用,专项终止")
- endpoint = urlparse(run.model_url)
- if endpoint.scheme != "https" or endpoint.hostname != "api.deepseek.com":
- raise RuntimeError("本专项只允许已配置的 DeepSeek HTTPS 模型地址")
- effective = run.release_snapshot or {
- "max_output_tokens": settings.max_output_tokens,
- "model_timeout": settings.model_timeout,
- "model_extra_body": settings.model_extra_body,
- }
- output_limit = effective["max_output_tokens"]
- if not 1 <= output_limit <= 1024 or not 0 < effective["model_timeout"] <= 30:
- raise RuntimeError("发布快照的输出或超时参数超过本次采样上限")
- extra = effective["model_extra_body"]
- for name in ("max_tokens", "max_completion_tokens", "max_output_tokens"):
- if name in extra and extra[name] > output_limit:
- raise RuntimeError("模型附加参数超过发布快照的输出上限")
- if extra.get("n", 1) != 1:
- raise RuntimeError("本专项仅允许单候选响应")
- os.environ[run.model_credential_env] = os.environ["DEEPSEEK_API_KEY"]
- report.update({
- "run_id": args.run_id, "conversation_id": args.conversation_id,
- "model": run.model_name, "provider": run.model_url,
- "effective_max_output_tokens": output_limit,
- "effective_model_timeout_seconds": effective["model_timeout"],
- "automatic_provider_retries": 0,
- })
- # 跨次执行共享三次总额度;进程被终止时保留已预留次数,禁止自动增加额度。
- budget_lock = state.lock(
- "verification:browser-model:allowance", timeout=100, blocking_timeout=2,
- )
- if not await budget_lock.acquire():
- raise RuntimeError("另一次浏览器模型专项正在执行")
- ledger, remaining = await read_allowance()
- ledger_run = {
- "run_id": args.run_id, "status": "RESERVED", "charged_attempts": remaining,
- }
- ledger["runs"].append(ledger_run)
- await asyncio.to_thread(write_report, ledger, BUDGET_PATH)
- allowance = CallAllowance(remaining)
- claimed = await claim(
- sessions, settings.tenant_concurrency, state=state,
- settings=settings, capacity_id=capacity_id,
- )
- if claimed is None or claimed[0] != identifier:
- raise RuntimeError("领取结果与浏览器运行不同,专项未调用模型")
- _, token = claimed
- async with retrieval_client() as knowledge:
- graph = build_graph(sessions, settings, knowledge.search, allowance=allowance)
- await graph.ainvoke(
- {"run_id": identifier, "token": token, "next_node": "model"},
- {"recursion_limit": 24},
- )
- except BaseException as failure:
- report["failure_type"] = type(failure).__name__
- failure_code = (
- failure.code if isinstance(failure, BusinessError) else type(failure).__name__
- )
- report["failure_code"] = failure_code
- not_sent = isinstance(failure, BusinessError) and failure.code in (
- ADMISSION_ERRORS | {"MODEL_SAMPLE_LIMIT"}
- )
- if identifier and token is not None:
- # 终止仅更新本人领取的运行,真实已发生用量由模型回调保留。
- async with sessions.begin() as session:
- run = await session.get(AgentRun, identifier)
- await session.get(Conversation, run.conversation_id, with_for_update=True)
- await session.refresh(run, with_for_update=True)
- if run.status == "RUNNING" and run.lease_token == token:
- run.status = "FAILED"
- run.error_code = failure_code[:64]
- run.lease_until = None
- await finish_run(session, run, "run.failed", {"code": run.error_code})
- await session.execute(update(ModelAttempt).where(
- ModelAttempt.run_id == identifier, ModelAttempt.status == "STARTED",
- ).values(status="NOT_SENT" if not_sent else "UNKNOWN"))
- raise
- finally:
- try:
- await release(state, capacity_id)
- if identifier and args.report:
- report.update(await snapshot(sessions, identifier))
- # 回调先检查次数和预算;只有预算事务通过后才进入供应商请求。
- report["callback_admission_attempts"] = allowance.started
- report["provider_attempts_started"] = len(report["token_entries"])
- report["provider_attempts_received"] = sum(
- item["status"] == "RECEIVED" for item in report["model_attempts"]
- )
- report["provider_attempts_result_unknown"] = sum(
- item["status"] in {"UNKNOWN", "PENDING"} for item in report["token_entries"]
- )
- report["admission_rejections"] = sum(
- item["status"] == "NOT_SENT" for item in report["model_attempts"]
- )
- if ledger_run is not None:
- ledger_run.update({
- "status": "RECORDED",
- "charged_attempts": report["provider_attempts_started"],
- "callback_admission_attempts": allowance.started,
- "failure_code": report.get("failure_code"),
- })
- await asyncio.to_thread(write_report, ledger, BUDGET_PATH)
- report["cumulative_provider_attempts"] = sum(
- item["charged_attempts"] for item in ledger["runs"]
- )
- report["elapsed_seconds"] = round(time.monotonic() - started, 3)
- await asyncio.to_thread(write_report, report, args.report)
- evidence = str(Path(args.report).with_name(
- f"customer-model-worker-{args.run_id}.json",
- ))
- await asyncio.to_thread(write_report, report, evidence)
- finally:
- if budget_lock is not None and await budget_lock.owned():
- await budget_lock.release()
- await state.aclose()
- await engine.dispose()
- if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="浏览器 AI 有界验证的单次工作进程")
- parser.add_argument("--preflight", action="store_true")
- parser.add_argument("--prepare-account", action="store_true")
- parser.add_argument("--cleanup-account", action="store_true")
- parser.add_argument("--run-id")
- parser.add_argument("--conversation-id")
- parser.add_argument("--report")
- parser.add_argument("--timeout-seconds", type=int, default=80, choices=range(1, 86))
- asyncio.run(main(parser.parse_args()))
|